Compare commits

..

31 Commits

Author SHA1 Message Date
mars
9abfbfcedd feat(player): theme-3 PC 列表弹窗详情与收件箱样式修复
投注/资金/充值/返水列表改为 theme-3 实心弹窗,修复收件箱背景、左侧裁切与滚动。
2026-07-10 17:01:53 +08:00
mars
4ccb0cde6d feat(player): 优化PC端优胜冠军下单弹窗UI与交互体验 - 弹窗宽度增至420px - Hero区域水平布局 - 赔率变化展示优化 - 快捷金额增加1000选项 - 弹窗进入动画 - 按钮微交互 - SVG图标 2026-07-09 15:16:11 +08:00
mars
e7e2b77906 feat(player): theme-3 移动端/PC 双端拆分与桌面投注工作台
新增 DesktopShell 及桌面端首页、赛事、钱包、记录等页面,原 H5 视图迁移至 Mobile* 并由路由 wrapper 按视口切换。补齐右侧投注单栏、赔率快捷浮层、联赛/赛事侧栏、串关与定位能力;桌面下注成功改为全局 Toast,修复侧栏成功遮罩被裁剪与底部汇总黑底。同步悬浮客服、公告卡片、三语 i18n、桌面样式体系,以及 API 赛事与 shared 包相关调整。
2026-07-07 17:07:38 +08:00
fee34feee8 style(player): theme-3 全站 h5bg2 背景与首页账单 UI 优化 2026-06-23 17:57:34 +08:00
dd7ca2f4e4 fix(player): 修复主题分支网站图标不显示
favicon.svg 原外链 /logo.png 浏览器无法加载;改为内嵌 base64

index.html 增加 logo.png 作为 favicon 回退;theme-3 theme-color 对齐 #0F2027
2026-06-23 16:29:09 +08:00
233e9c891a style(player): theme-3 玻璃卡片与认证页视觉优化
- 认证页减轻背景遮罩,玻璃表单与渐变登录按钮

- 全局 glass 卡片:半透明底+中性描边,珊瑚色仅用于 auth-card

- 登录提示/确认弹窗、注单/钱包/首页等卡片统一风格

- 消息列表移除外边框与未读圆点,保留未读标签
2026-06-23 16:21:40 +08:00
a379f9e522 fix(dev): 本地 API 默认端口改为 3100 避开 Windows 保留段
Hyper-V 常保留 2960-3059,3000 会 EACCES;ensure-port-free 与 Vite 代理从 apps/api/.env 读取 PORT
2026-06-23 15:10:43 +08:00
aa1a82e8e3 feat(catalog): 玩家端隐藏已结算赛事与冠军盘联赛
- listPublished/listUpcomingPublished 排除 SETTLED 及冠军盘已结算联赛

- outright.listForPlayer 仅返回 PUBLISHED

- 首页 todayMatches 过滤 SETTLED;管理端已结算冠军盘隐藏下架按钮

- 补充 matches/outright 单测
2026-06-23 15:01:54 +08:00
ce28f1719b fix(player): dev Vite proxy reads API PORT; wallet list rows blend with page background 2026-06-23 14:52:04 +08:00
b91aa74be1 fix(player): captcha canvas colors follow theme CSS variables (theme-3) 2026-06-23 14:34:12 +08:00
a9f067744d fix(admin): 恢复 Settlement adminSelectionLabel 导入并修正 walletRemarkLabel 类型 2026-06-23 14:01:04 +08:00
1239dd7ac2 sync(theme-3): 从 main 同步优胜赛结算态与钱包流水展示优化
- checkout shared/api/admin 自 d3c2114
- player: outright 结算态逻辑、wallet txAmountClass、i18n 新增 key(保留主题 CSS 变量)
2026-06-23 13:51:07 +08:00
5c1e06e873 sync(admin): 从 main 同步列表布局、滚动固定与侧栏选中
checkout main: App.vue, ManageLayout, AgentManager, Matches, LeagueMatches*, AgentDirectPlayersView
2026-06-22 16:51:55 +08:00
d314b3c94d sync(theme-3): 同步 main Admin 菜单权限修复与 i18n 2026-06-22 16:23:33 +08:00
d8e08aaed0 sync(theme-3): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-3 样式。
2026-06-22 14:11:47 +08:00
f92c5e8a59 feat(admin): 同步单赛事筛选与排序功能 (从 main 分支) 2026-06-18 17:46:16 +08:00
2ed072f2f3 style: separate header back-bar and tabs in InboxHubView on theme-3 2026-06-18 17:37:23 +08:00
6b8dde5924 feat: align homepage banner navigation and default banner logic with main 2026-06-18 17:30:57 +08:00
3eace49899 fix(admin): 发版 chunk 自动刷新、nginx 缓存策略与审计页 i18n
lazy route 加载失败时刷新入口;index.html 不缓存;补全 audit.col.operator/ip 等拆包遗漏 key。
2026-06-18 16:59:53 +08:00
e4d6d5c048 perf(admin): 实现切页即时导航与列表 loading 壳
非阻塞 session 刷新、侧边栏 chunk 预取、KeepAlive 对齐,以及按 tab 延迟加载重型列表页。
2026-06-18 16:47:08 +08:00
7b5a752b8f feat(admin): 将新增赛事按钮移至联赛操作栏,并移除列表下方的旧按钮 2026-06-18 15:41:38 +08:00
2487724692 perf(admin): 优化管理端页面切换性能与包体积 (同步自 main)
- 阶段 A: 增加 KeepAlive 缓存高频列表页,改用 onActivated 进行后台静默刷新,优化 tab 切换与 HomeEntry 闪屏

- 阶段 B: beforeEach 守卫在 TTL 内走同步快速路径,api 请求拦截器缓存 token 避免重复解析 JWT

- 阶段 C: 引入 unplugin 插件启用 Element Plus 组件按需加载,清理 App.vue 中 960+ 行冗余暗色主题 CSS

- i18n 拆包: 将三语文案包提取为独立懒加载 chunks,主包体积从 209KB 减少至 99KB (降低 53%)
2026-06-18 15:31:57 +08:00
533d748605 docs: 从 main 同步 AGENTS、文档与部署脚本 2026-06-18 14:59:17 +08:00
58f0876017 fix(player): 冠军盘弹窗支持赔率变更轮询与确认下注
- 打开弹窗后定时拉取最新赔率并展示变更提示
- 赔率变动时按钮切换为「接受变更并下注」,提交前自动锁定新版本
- 接口返回 ODDS_CHANGED 时刷新赔率并引导用户重新确认
2026-06-18 13:54:37 +08:00
03c625f7b1 style(theme-3): 修复详情页顶部导航栏上方的空白边距及滚动内容穿透问题 2026-06-18 10:57:43 +08:00
2b70ceb77b style(theme-3): 个人中心快捷操作中移除消息中心入口,使排版更美观 2026-06-18 10:56:14 +08:00
6881d325d5 feat(theme-3): sync inbox/announcements/presence/deposit/search from main 2026-06-18 10:02:32 +08:00
0d430857c7 fix(player): 赛事详情页恢复顶部 8px 间距(theme-3)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:18:58 +08:00
78322578d3 fix(admin/player/api): 同步单关串关持久化、仅串关可选与盘口状态展示
1. 后台/API:赛事详情返回 allowSingle/allowParlay,管理端 mapMarkets 正确回读
2. 玩家端:仅关单关时仍可点选加入串关;投注抽屉禁用单关提交并提示
3. 玩家端:盘口暂停/已关闭状态标签;MarketTypeTile 展示 statusLabel

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:09:55 +08:00
d2c0b575b7 feat(player): 统一移动端主题样式并优化多页面视觉一致性
将玩家端组件与核心页面全面切换为统一主题变量,优化卡片、按钮、弹窗、列表和盘口相关模块的视觉表现,并补充多语言文案以匹配新的交互与布局风格。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 15:55:28 +08:00
4a93fcfce0 feat(player): 全站 UI 主题重构与布局优化 — 从暗金奢华风格迁移至 Pinnacle 风格蓝白专业主题
一、全局主题重构(52 文件)
  - 设计体系从深色/金色奢华调性全面迁移至 Pinnacle 风格的清爽专业调性
  - 主色:深海蓝 #003D6B,辅色:橙色 #F8971F / #E8870A(赔率强调色)
  - 背景从纯黑/深灰改为白色/浅灰 (#F5F7FA, #E8EDF2)
  - 更新 CSS 变量设计令牌体系:--primary, --border, --text-muted, --bg-body, --bg-hover, --radius-sm 等
  - styles.css 全量重写,所有组件 scoped CSS 同步适配

二、紧凑布局优化
  - 首页:BannerCarousel 轮播高度缩减 (clamp 148→120px),MatchBetCard 内边距/间距/字号全面收紧
  - 首页:赛事卡片 padding 14px→10px,队旗尺寸 72×48→56×38,VS 区域缩小
  - 个人中心:钱包横幅宽高比 2/1→1.75/1,设置单元格 min-height 48→42px
  - 钱包页:交易行 padding/字号收紧,金额字号 15→14px
  - MainLayout:顶栏高度 50→48px,底部导航间距微调

三、对比度与可读性增强
  - 赛事详情/投注页背景从透明加深至 #E8EDF2
  - 所有卡片边框从 #E5E7EB 加深至 #CBD5E1,阴影强度提升
  - 盘口选择面板背景 #F5F7FA→#EDF0F4,增加 border-top 分隔线
  - 赔率颜色从 #F8971F 加深至 #E8870A,选中态 border-width 加粗至 2px
  - 标签字号增大并加粗 (font-weight: 600),颜色从 #6B7280 加深至 #4B5563

四、首页快捷入口 & 个人中心网格布局
  - 首页新增 4 宫格快捷入口(投注/充值/账单/活动),彩色图标区分功能
  - 个人中心重构为「4 宫格快捷操作 + 列表」结构:钱包/充值/返水/记录
  - 移除原个人中心金色入口列表项,精简设置列表为修改资料/投注规则/语言

五、顶部导航栏重设计
  - Logo 从 img 标签替换为内联 SVG 纯文字:THE(灰) + BET(深蓝) + 365(橙)
  - 客服按钮改为纯图标圆形按钮 (32×32px),增加竖线分隔符
  - Chip 高度 34→32px,间距 8→6px
  - 底部导航激活态指示条加粗至 2.5px,左右缩进从 22% 调至 18%

六、SVG Favicon
  - 新增纯路径 SVG favicon(无 <text> 元素,确保浏览器 favicon 沙箱兼容)
  - 深蓝圆角方块 + 白色 "B" 字母路径 + 橙色圆点 + 白色 "365" 数字路径
  - index.html 更新 favicon 引用为 /favicon.svg

🤖 Generated with [Qoder][https://qoder.com]
2026-06-16 12:24:41 +08:00
132 changed files with 6513 additions and 5749 deletions

View File

@@ -4,7 +4,8 @@ JWT_SECRET=change-me-in-production-use-long-random-string
JWT_PLAYER_EXPIRES=24h
JWT_ADMIN_EXPIRES=2h
JWT_AGENT_EXPIRES=8h
PORT=3000
# 本地开发建议 3100Windows Hyper-V 常保留 296030593000 会 EACCES
PORT=3100
NODE_ENV=development
UPLOAD_DIR=

View File

@@ -2,11 +2,6 @@
> 给 AI 编程助手Cursor 等)看的项目速查手册,浓缩开发约定与易错点。
> 人类日常开发请优先看 `README.md` 与 `docs/`。
如果你是AI请每次读了这个文件就在已有的次数上+1
当前被读次数: 3
## 目录
@@ -162,7 +157,7 @@
| --------- | ----------- | ------------------------------------------------------------------- |
| `main` | 暗金主题 | 生产默认发版分支 |
| `theme-2` | Pinnacle 蓝白 | 玩家 UI 与 main 分叉,**禁止** `git checkout main -- apps/player/...` 整文件覆盖 |
| `theme-3` | 统一移动端视觉 | 同 theme-2 |
| `theme-3` | Dusk Coral 珊瑚玻璃(移动 + PC双端 | 同 theme-2 |
| `theme-4` | 海军蓝暗色极简 | 含悬浮客服等独有组件 |

View File

@@ -37,8 +37,6 @@ declare module 'vue' {
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"build": "nest build",
"dev": "node ../../scripts/ensure-port-free.mjs 3000 && nest start --watch",
"dev": "node ../../scripts/ensure-port-free.mjs && nest start --watch",
"start": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",

View File

@@ -190,7 +190,7 @@ export class PlayerController {
this.content.listActive('BANNER', locale),
this.content.listActiveAnnouncements(locale),
this.matches.listPublished(locale, undefined, { includeMarkets: true }),
this.matches.listUpcomingPublished(locale, { includeMarkets: true }),
this.matches.listUpcomingPublished(locale),
this.systemConfig.getInboxFeatureEnabled(),
]);
const timeZone = safeTimeZone(headerTimeZone);

View File

@@ -1595,11 +1595,10 @@ export class MatchesService {
/** 未来 N 天内开赛的已发布赛事(按开赛时间升序,不限 isHot */
async listUpcomingPublished(
locale = 'en-US',
options?: { limit?: number; days?: number; includeMarkets?: boolean },
options?: { limit?: number; days?: number },
) {
const limit = options?.limit ?? 50;
const days = options?.days ?? 3;
const includeMarkets = options?.includeMarkets ?? false;
const now = new Date();
const end = new Date(now);
end.setDate(end.getDate() + days);
@@ -1626,14 +1625,14 @@ export class MatchesService {
homeTeam: true,
awayTeam: true,
score: true,
markets: includeMarkets ? this.playerMarketInclude : this.playerMarketStatusInclude,
markets: this.playerMarketInclude,
},
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
take: limit,
});
return Promise.all(
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: !includeMarkets })),
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: false })),
);
}

View File

@@ -3,14 +3,14 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#001A33" />
<meta name="theme-color" content="#0F2027" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>TheBet365</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #001A33; }
html, body { margin: 0; padding: 0; height: 100%; background: #F5F7FA; }
#app-loading {
position: fixed;
inset: 0;
@@ -18,7 +18,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: #001A33;
background: #F5F7FA;
z-index: 9999;
}
#app-loading svg {
@@ -34,11 +34,40 @@
<body>
<div id="app-loading">
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="24" r="20" stroke="rgba(255,255,255,0.14)" stroke-width="3" fill="none" />
<circle cx="24" cy="24" r="20" stroke="rgba(255,255,255,0.72)" stroke-width="3" fill="none" stroke-linecap="round" stroke-dasharray="31.4 94.2" />
<defs>
<linearGradient id="gs-track" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#003D6B" stop-opacity="0.18" />
<stop offset="50%" stop-color="#005B9F" stop-opacity="0.25" />
<stop offset="100%" stop-color="#003D6B" stop-opacity="0.18" />
</linearGradient>
<linearGradient id="gs-arc" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#E8F4FD" />
<stop offset="22%" stop-color="#7CB9E8" />
<stop offset="50%" stop-color="#005B9F" />
<stop offset="78%" stop-color="#003D6B" />
<stop offset="100%" stop-color="#002847" />
</linearGradient>
<filter id="gs-glow">
<feGaussianBlur stdDeviation="1.2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<radialGradient id="gs-dot" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#E8F4FD" />
<stop offset="60%" stop-color="#7CB9E8" />
<stop offset="100%" stop-color="#005B9F" />
</radialGradient>
</defs>
<circle cx="24" cy="24" r="20" stroke="url(#gs-track)" stroke-width="3" fill="none" />
<circle cx="24" cy="24" r="20" stroke="url(#gs-arc)" stroke-width="3" fill="none" stroke-linecap="round" stroke-dasharray="31.4 94.2" filter="url(#gs-glow)" />
<circle cx="24" cy="4" r="2.5" fill="url(#gs-dot)" filter="url(#gs-glow)">
<animate attributeName="r" values="2.5;3.2;2.5" dur="1.2s" repeatCount="indefinite" />
</circle>
</svg>
</div>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

View File

@@ -0,0 +1,44 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(__dirname, '..');
function flatten(obj, prefix = '') {
const out = {};
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(out, flatten(v, key));
else out[key] = v;
}
return out;
}
function extractKeysFromDir(dir) {
const keys = new Set();
const re = /\bt\s*\(\s*['"`]([^'"`]+)['"`]/g;
function walk(d) {
for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
const p = path.join(d, ent.name);
if (ent.isDirectory()) walk(p);
else if (ent.name.endsWith('.vue')) {
const src = fs.readFileSync(p, 'utf8');
let m;
while ((m = re.exec(src))) keys.add(m[1]);
}
}
}
walk(dir);
return keys;
}
const zhMod = await import('../src/i18n/zh-CN.ts');
const zh = flatten(zhMod.default);
const dirs = ['src/views/desktop', 'src/components/desktop'].map((d) => path.join(root, d));
const used = new Set();
for (const d of dirs) extractKeysFromDir(d).forEach((k) => used.add(k));
const missing = [...used].filter((k) => !(k in zh)).sort();
console.log(`Missing keys (${missing.length}):`);
missing.forEach((k) => console.log(` ${k}`));

View File

@@ -1,19 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 750 280" fill="none">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="750" y2="280" gradientUnits="userSpaceOnUse">
<stop stop-color="#1A1A1A"/>
<stop offset="0.5" stop-color="#252015"/>
<stop offset="1" stop-color="#000000"/>
<stop stop-color="#1A3038"/>
<stop offset="0.5" stop-color="#203A43"/>
<stop offset="1" stop-color="#0F2027"/>
</linearGradient>
<linearGradient id="gold" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="rgba(255,255,255,0.72)"/>
<stop stop-color="#FFB980"/>
<stop offset="1" stop-color="#F4A261"/>
</linearGradient>
</defs>
<rect width="750" height="280" fill="url(#bg)"/>
<circle cx="620" cy="80" r="120" fill="#FFFFFF" opacity="0.06"/>
<circle cx="680" cy="200" r="80" fill="#FFFFFF" opacity="0.08"/>
<circle cx="620" cy="80" r="120" fill="#F4A261" opacity="0.06"/>
<circle cx="680" cy="200" r="80" fill="#F4A261" opacity="0.08"/>
<text x="40" y="110" font-family="Arial, sans-serif" font-size="42" font-weight="800" fill="url(#gold)">TheBet365</text>
<text x="40" y="160" font-family="Arial, sans-serif" font-size="22" fill="#8E8E93">足球赛事火热进行中</text>
<rect x="40" y="190" width="140" height="4" fill="#FFFFFF" opacity="0.5" rx="2"/>
<text x="40" y="160" font-family="Arial, sans-serif" font-size="22" fill="#8FA3AD">足球赛事火热进行中</text>
<rect x="40" y="190" width="140" height="4" fill="#F4A261" opacity="0.5" rx="2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1018 B

View File

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120" fill="none" aria-hidden="true">
<circle cx="60" cy="60" r="48" stroke="#FFFFFF" stroke-width="4" opacity="0.5"/>
<circle cx="60" cy="60" r="22" stroke="rgba(255,255,255,0.72)" stroke-width="4"/>
<path d="M60 8 L68 32 L60 44 L52 32 Z" fill="#FFFFFF"/>
<path d="M60 112 L68 88 L60 76 L52 88 Z" fill="#FFFFFF"/>
<path d="M8 60 L32 52 L44 60 L32 68 Z" fill="#FFFFFF"/>
<path d="M112 60 L88 68 L76 60 L88 52 Z" fill="#FFFFFF"/>
<circle cx="60" cy="60" r="48" stroke="#F4A261" stroke-width="4" opacity="0.5"/>
<circle cx="60" cy="60" r="22" stroke="#FFB980" stroke-width="4"/>
<path d="M60 8 L68 32 L60 44 L52 32 Z" fill="#F4A261"/>
<path d="M60 112 L68 88 L60 76 L52 88 Z" fill="#F4A261"/>
<path d="M8 60 L32 52 L44 60 L32 68 Z" fill="#F4A261"/>
<path d="M112 60 L88 68 L76 60 L88 52 Z" fill="#F4A261"/>
</svg>

Before

Width:  |  Height:  |  Size: 504 B

After

Width:  |  Height:  |  Size: 489 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 697 KiB

View File

@@ -56,6 +56,7 @@ function goDetail() {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
box-shadow: var(--shadow);
text-decoration: none;
color: inherit;
cursor: pointer;
@@ -69,7 +70,7 @@ function goDetail() {
border: none;
border-radius: 0;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
background: var(--bg-body);
}
.marquee-bar.embedded .marquee-badge {
@@ -87,9 +88,9 @@ function goDetail() {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
color: var(--text);
background: var(--surface-active);
border: 1px solid var(--border-strong);
color: #FFFFFF;
background: var(--gradient-primary);
border: none;
border-radius: 4px;
}
@@ -112,7 +113,7 @@ function goDetail() {
padding-right: 80px;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
color: var(--text-secondary);
white-space: nowrap;
}
@@ -121,3 +122,5 @@ function goDetail() {
100% { transform: translateX(-50%); }
}
</style>

View File

@@ -24,9 +24,11 @@ const { visible, message } = useAppToast();
max-width: min(90vw, 360px);
padding: 10px 18px;
border-radius: 8px;
border: 1px solid var(--border-gold-soft);
background: rgba(22, 22, 22, 0.96);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
border: 1px solid var(--border-active);
background: var(--bg-card-elevated);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: var(--shadow-lg);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;

View File

@@ -44,12 +44,12 @@ const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl'));
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-gold-soft);
background: var(--bg-body);
color: var(--primary-light);
box-shadow: var(--shadow);
border: none;
background: var(--gradient-primary);
color: #FFFFFF;
box-shadow: var(--shadow-glow), 0 2px 8px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: background 0.2s, transform 0.2s;
transition: opacity 0.2s, transform 0.2s;
}
.back-top-btn.above-nav {
@@ -57,7 +57,7 @@ const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl'));
}
.back-top-btn:active {
background: var(--surface-accent);
opacity: 0.9;
transform: scale(0.96);
}

View File

@@ -125,14 +125,23 @@ onUnmounted(stopAutoPlay);
class="slide"
@click="onBannerClick(banner)"
>
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="title(banner)"
class="slide-img"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
<template v-if="imageUrl(banner)">
<img
:src="imageUrl(banner)"
alt=""
class="slide-backdrop"
aria-hidden="true"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
<img
:src="imageUrl(banner)"
:alt="title(banner)"
class="slide-img"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
</template>
<div v-else class="slide-fallback">{{ title(banner) }}</div>
</div>
</div>
@@ -159,65 +168,106 @@ onUnmounted(stopAutoPlay);
<style scoped>
.carousel {
position: relative;
margin: 0 -16px 14px;
margin: -12px -16px 10px;
overflow: hidden;
}
.media {
position: relative;
overflow: hidden;
background: var(--bg-card);
}
.viewport {
overflow: hidden;
width: 100%;
height: clamp(120px, 34vw, 150px);
}
.track {
display: flex;
height: 100%;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide {
position: relative;
flex: 0 0 100%;
min-width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: linear-gradient(135deg, #0B0F1A, #141B2D);
cursor: pointer;
}
.slide-img {
width: 100%;
height: 180px;
.slide::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
background:
linear-gradient(90deg, rgba(15, 32, 39, 0.12), rgba(15, 32, 39, 0.03) 48%, rgba(15, 32, 39, 0.12));
box-shadow: inset 0 -1px 0 rgba(244, 162, 97, 0.08);
pointer-events: none;
}
.slide-backdrop {
position: absolute;
inset: -18px;
width: calc(100% + 36px);
height: calc(100% + 36px);
object-fit: cover;
filter: blur(16px);
opacity: 0.2;
transform: scale(1.04);
pointer-events: none;
}
.slide-img {
position: relative;
z-index: 2;
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
.slide-fallback {
height: 180px;
position: relative;
z-index: 2;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.25), var(--secondary));
background: linear-gradient(135deg, rgba(244, 162, 97, 0.08), #141B2D);
font-size: 22px;
font-weight: 800;
color: var(--primary-light);
font-weight: 700;
color: var(--primary);
}
.media .nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
width: 26px;
height: 26px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: var(--primary-light);
background: var(--bg-glass);
color: var(--primary);
border: 1px solid var(--border);
font-size: 22px;
padding-bottom: 4px;
font-size: 18px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.nav.prev { left: 8px; }
@@ -225,29 +275,30 @@ onUnmounted(stopAutoPlay);
.media .dots {
position: absolute;
bottom: 10px;
bottom: 8px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 8px;
gap: 6px;
z-index: 2;
}
.dot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
border: 2px solid transparent;
background: rgba(148, 163, 184, 0.3);
border: 1.5px solid transparent;
padding: 0;
transition: all 0.25s ease;
}
.dot.active {
width: 22px;
border-radius: 6px;
background: var(--gradient-gold);
border-color: var(--primary-light);
box-shadow: none;
width: 18px;
border-radius: 5px;
background: var(--gradient-primary);
border-color: var(--primary);
box-shadow: 0 0 8px rgba(244, 162, 97, 0.4);
}
</style>

View File

@@ -65,9 +65,9 @@ function close() {
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--border-gold-soft);
background: var(--bg-card);
color: var(--primary-light);
border: 1px solid #E5E7EB;
background: #FFFFFF;
color: #0F2027;
font-size: 14px;
font-weight: 800;
line-height: 1;
@@ -79,7 +79,7 @@ function close() {
position: fixed;
inset: 0;
z-index: 205;
background: rgba(0, 0, 0, 0.72);
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
@@ -93,11 +93,11 @@ function close() {
max-width: 340px;
max-height: 85vh;
overflow-y: auto;
background: var(--gradient-card);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 16px 14px 14px;
box-shadow: var(--shadow), var(--glow-gold);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.close-x {
@@ -107,15 +107,15 @@ function close() {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-elevated);
color: var(--text-muted);
background: #F5F7FA;
color: #6B7280;
font-size: 14px;
}
.title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
color: #0F2027;
text-align: center;
margin-bottom: 12px;
padding-right: 24px;
@@ -126,14 +126,14 @@ function close() {
gap: 10px;
margin-bottom: 14px;
font-size: 13px;
color: var(--text-muted);
color: #6B7280;
line-height: 1.55;
}
.guide-body :deep(.flow-name) {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
color: #0F2027;
margin: 0 0 8px;
}
@@ -152,21 +152,24 @@ function close() {
.guide-body :deep(.flow-block) {
padding-top: 10px;
border-top: 1px solid var(--border);
border-top: 1px solid #E5E7EB;
}
.guide-body :deep(.rules-link) {
margin: 4px 0 0;
font-size: 12px;
padding-top: 8px;
border-top: 1px dashed var(--border);
border-top: 1px dashed #E5E7EB;
}
.btn-ok {
width: 100%;
padding: 10px;
border-radius: 6px;
border-radius: 8px;
font-size: 14px;
font-weight: 800;
background: #F4A261;
color: #FFFFFF;
border: none;
}
</style>

View File

@@ -149,15 +149,16 @@ function goDetail() {
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
border-radius: var(--radius-lg);
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s;
transition: transform 0.15s, box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
}
.bet-card:active {
background: rgba(255, 255, 255, 0.02);
transform: scale(0.99);
}
.card-body {
@@ -178,9 +179,9 @@ function goDetail() {
.cashback-tag {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: var(--surface-accent);
border: 1px solid var(--border-gold-soft);
color: var(--accent);
background: rgba(244, 162, 97, 0.08);
border: 1px solid rgba(244, 162, 97, 0.2);
border-radius: 4px;
padding: 1px 6px;
}
@@ -188,9 +189,9 @@ function goDetail() {
.bet-type-tag {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: var(--surface-accent);
border: 1px solid var(--border-gold-soft);
color: var(--primary);
background: rgba(244, 162, 97, 0.06);
border: 1px solid rgba(244, 162, 97, 0.15);
border-radius: 4px;
padding: 1px 6px;
letter-spacing: 0.02em;
@@ -202,7 +203,6 @@ function goDetail() {
font-weight: 600;
flex-shrink: 0;
margin-left: auto;
opacity: 0.7;
}
.title {
@@ -234,8 +234,8 @@ function goDetail() {
.odds-badge {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
background: var(--surface-accent);
color: var(--warning);
background: rgba(255, 179, 0, 0.08);
border-radius: 4px;
padding: 1px 6px;
flex-shrink: 0;
@@ -262,13 +262,13 @@ function goDetail() {
}
.return-amt.highlight { color: var(--success); }
.return-amt.pending { color: var(--primary-light); }
.return-amt.pending { color: var(--warning); }
.return-amt.lost { color: var(--danger); }
.chevron {
flex-shrink: 0;
font-size: 18px;
color: var(--neutral);
color: var(--border);
margin-left: 2px;
line-height: 1;
}

View File

@@ -637,7 +637,7 @@ watch(
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
background: rgba(0, 0, 0, 0.5);
z-index: 200;
display: flex;
align-items: flex-end;
@@ -646,13 +646,13 @@ watch(
.drawer {
width: 100%;
max-height: 88vh;
background: var(--bg-body);
background: var(--bg-card);
border-radius: 16px 16px 0 0;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: var(--shadow);
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.3);
}
.drawer-head {
@@ -703,7 +703,7 @@ watch(
align-items: center;
gap: 8px;
padding: 10px 14px;
background: var(--bg-card);
background: rgba(244, 162, 97, 0.04);
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
@@ -711,7 +711,7 @@ watch(
.balance-bar strong {
justify-self: end;
color: var(--text);
color: var(--primary);
font-size: 16px;
font-weight: 900;
}
@@ -737,7 +737,7 @@ watch(
min-height: 38px;
border-radius: 6px 6px 0 0;
border: 1px solid var(--border);
background: var(--bg-input);
background: var(--bg-elevated);
color: var(--text-muted);
font-size: 13px;
font-weight: 800;
@@ -745,8 +745,8 @@ watch(
.slip-tab.active {
border-color: var(--primary);
background: var(--surface-active);
color: var(--text);
background: rgba(244, 162, 97, 0.06);
color: var(--primary);
}
.tab-count {
@@ -758,7 +758,7 @@ watch(
margin-left: 6px;
border-radius: 9px;
background: var(--primary);
color: var(--on-primary);
color: #FFFFFF;
font-size: 11px;
font-weight: 900;
}
@@ -791,7 +791,7 @@ watch(
.slip-item--single {
border-color: var(--primary);
background: var(--surface-active);
background: rgba(244, 162, 97, 0.03);
}
.item-main {
@@ -852,12 +852,12 @@ watch(
min-height: 48px;
padding: 0 14px;
border-radius: 8px;
background: transparent;
border: 1px solid var(--border-strong);
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--primary);
color: var(--primary);
font-size: 14px;
font-weight: 900;
box-shadow: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.btn-add-parlay span {
@@ -868,7 +868,7 @@ watch(
height: 18px;
border-radius: 50%;
background: var(--primary);
color: var(--on-primary);
color: #FFFFFF;
font-size: 14px;
line-height: 1;
}
@@ -905,16 +905,16 @@ watch(
margin: 0 0 10px;
padding: 10px 11px;
border-radius: 6px;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.25);
color: rgba(239, 68, 68, 0.95);
background: rgba(255, 77, 79, 0.12);
border: 1px solid rgba(255, 77, 79, 0.24);
color: var(--danger);
font-size: 12px;
line-height: 1.45;
}
.parlay-meta {
margin: 8px 2px 10px;
color: var(--text-muted);
color: var(--primary);
font-size: 12px;
font-weight: 800;
}
@@ -939,7 +939,7 @@ watch(
.stake-head strong,
.return strong {
color: var(--text);
color: var(--primary);
font-size: 16px;
font-weight: 900;
}
@@ -1024,10 +1024,11 @@ watch(
.quick-stakes button {
min-height: 50px;
background: var(--bg-card);
background: var(--bg-card-elevated);
color: var(--text);
font-size: 20px;
font-weight: 800;
border: 1px solid var(--border);
}
.return {
@@ -1046,9 +1047,9 @@ watch(
grid-template-columns: 1fr;
gap: 10px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: var(--bg-body);
background: var(--bg-card);
border-top: 1px solid var(--border);
box-shadow: var(--shadow);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.2);
}
.drawer-foot--split {
@@ -1059,8 +1060,8 @@ watch(
width: 100%;
min-height: 48px;
border-radius: var(--radius-sm);
background: var(--primary);
color: var(--on-primary);
background: var(--gradient-primary);
color: #FFFFFF;
font-size: 16px;
font-weight: 900;
}

View File

@@ -34,27 +34,27 @@ const stats = computed(() => {
<template>
<div class="stats-panel">
<div class="stats-row main">
<div class="stat-cell">
<div class="stats-row">
<div class="stat-item">
<span class="stat-val">{{ stats.total }}</span>
<span class="stat-label">{{ t('history.stats_total') }}</span>
</div>
<div class="stat-cell won">
<div class="stat-item won">
<span class="stat-val">{{ stats.won }}</span>
<span class="stat-label">{{ t('history.stats_won') }}</span>
</div>
<div class="stat-cell lost">
<div class="stat-item lost">
<span class="stat-val">{{ stats.lost }}</span>
<span class="stat-label">{{ t('history.stats_lost') }}</span>
</div>
<div class="stat-cell pending">
<div class="stat-item pending">
<span class="stat-val">{{ stats.pending }}</span>
<span class="stat-label">{{ t('history.stats_pending') }}</span>
</div>
</div>
<div v-if="stats.push > 0" class="push-row">
<span class="stat-label">{{ t('history.stats_push') }}</span>
<span class="stat-val push">{{ stats.push }}</span>
<div class="stat-item push">
<span class="stat-val">{{ stats.push }}</span>
<span class="stat-label">{{ t('history.stats_push') }}</span>
</div>
</div>
<div class="stats-bar" :class="{ empty: stats.total === 0 }" aria-hidden="true">
<template v-if="stats.total > 0">
@@ -64,14 +64,14 @@ const stats = computed(() => {
<div v-if="stats.push > 0" class="bar-seg push" :style="{ flex: stats.push }" />
</template>
</div>
<div class="stats-row money">
<div class="money-cell">
<span class="stat-label">{{ t('history.stats_stake') }}</span>
<div class="stats-row secondary">
<div class="stat-item">
<span class="stat-val small">{{ formatMoney(stats.totalStake, locale) }}</span>
<span class="stat-label">{{ t('history.stats_stake') }}</span>
</div>
<div class="money-cell accent">
<span class="stat-label">{{ t('history.stats_return') }}</span>
<div class="stat-item">
<span class="stat-val small gold">{{ formatMoney(stats.totalReturn, locale) }}</span>
<span class="stat-label">{{ t('history.stats_return') }}</span>
</div>
</div>
</div>
@@ -79,133 +79,79 @@ const stats = computed(() => {
<style scoped>
.stats-panel {
position: relative;
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
padding: 8px 10px;
box-shadow: var(--shadow-card-sm);
overflow: hidden;
}
.stats-panel::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
border-radius: var(--radius-lg);
padding: 14px 12px 10px;
margin-bottom: 12px;
}
.stats-row {
display: grid;
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.stats-row.main {
grid-template-columns: repeat(4, minmax(0, 1fr));
.stats-row.secondary {
margin-bottom: 0;
margin-top: 8px;
justify-content: space-around;
}
.stats-row.money {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.stat-cell,
.money-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
min-width: 0;
.stat-item {
flex: 1;
text-align: center;
}
.money-cell {
align-items: flex-start;
text-align: left;
}
.money-cell.accent {
align-items: flex-end;
text-align: right;
}
.push-row {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 4px;
min-width: 0;
}
.stat-val {
display: block;
font-size: 15px;
font-weight: 800;
font-size: 18px;
font-weight: 900;
font-variant-numeric: tabular-nums;
color: var(--text);
line-height: 1.15;
}
.stat-val.small {
font-size: 12px;
font-weight: 700;
font-size: 14px;
color: var(--text-muted);
}
.stat-val.gold {
color: var(--primary-light);
color: var(--primary);
}
.stat-val.push {
font-size: 13px;
color: var(--text-muted);
}
.stat-cell.won .stat-val { color: var(--success); }
.stat-cell.lost .stat-val { color: var(--danger); }
.stat-cell.pending .stat-val { color: var(--primary-light); }
.stat-item.won .stat-val { color: var(--success); }
.stat-item.lost .stat-val { color: var(--danger); }
.stat-item.pending .stat-val { color: var(--warning); }
.stat-item.push .stat-val { color: var(--text-muted); }
.stat-label {
display: block;
font-size: 9px;
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.03em;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
letter-spacing: 0.04em;
margin-top: 2px;
}
.stats-bar {
display: flex;
height: 3px;
height: 4px;
border-radius: 2px;
overflow: hidden;
gap: 1px;
margin-top: 6px;
gap: 2px;
}
.stats-bar.empty {
background: rgba(255, 255, 255, 0.08);
background: var(--bg-elevated);
}
.bar-seg {
border-radius: 1px;
border-radius: 2px;
transition: flex 0.3s ease;
}
.bar-seg.won { background: var(--success); }
.bar-seg.lost { background: var(--danger); }
.bar-seg.pending { background: var(--primary-light); }
.bar-seg.push { background: var(--neutral); }
.bar-seg.pending { background: var(--warning); }
.bar-seg.push { background: var(--text-muted); }
</style>

View File

@@ -16,7 +16,7 @@ interface Particle {
color: string; shape: number;
}
const COLORS = ['#FFFFFF', '#FFD700', '#F0C040', '#C8A02E', '#E6C84C', '#B8960C'];
const COLORS = ['#F4A261', '#FFB980', '#FFDDD2', '#F4A261', '#E76F51', '#FFDDD2'];
let w = 0;
let h = 0;
@@ -111,8 +111,8 @@ onUnmounted(stop);
<canvas ref="canvasRef" class="confetti-canvas" />
<div class="success-card">
<svg class="check" 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="currentColor" stroke-width="3" />
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
<circle class="check-circle" cx="26" cy="26" r="24" fill="none" stroke="#10B981" stroke-width="3" />
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="#10B981" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="success-text">{{ t('bet.place_success') }}</p>
</div>
@@ -124,8 +124,10 @@ onUnmounted(stop);
.bet-success-overlay {
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.82);
z-index: 5000;
background: rgba(15, 32, 39, 0.88);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
@@ -151,7 +153,6 @@ onUnmounted(stop);
.check {
width: 72px;
height: 72px;
color: var(--primary);
}
.check-circle {
@@ -167,11 +168,12 @@ onUnmounted(stop);
}
.success-text {
color: var(--primary-light);
color: var(--primary);
font-size: 18px;
font-weight: 800;
letter-spacing: 0.04em;
animation: text-up 0.4s 0.4s ease both;
text-shadow: 0 0 20px rgba(244, 162, 97, 0.4);
}
@keyframes card-pop {

View File

@@ -2,28 +2,17 @@
import { ref, onMounted, onUnmounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { playerAvatarUrl, randomAvatarKey } from '@thebet365/shared';
import { useAuthStore } from '../stores/auth';
import { formatMoney } from '../utils/localeDisplay';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const props = withDefaults(defineProps<{ hideAvatar?: boolean }>(), { hideAvatar: false });
const { locale, t } = useI18n();
const { profileRaw, refreshProfile, avatarUrl } = usePlayerProfile();
const auth = useAuthStore();
const { profileRaw, refreshProfile } = usePlayerProfile();
const router = useRouter();
const open = ref(false);
const root = ref<HTMLElement | null>(null);
const wallet = computed(() => profileRaw.value?.wallet ?? null);
const displayAvatarUrl = computed(() => {
if (avatarUrl.value) return avatarUrl.value;
const seed = auth.user?.username;
return seed ? playerAvatarUrl(randomAvatarKey(seed)) : null;
});
function amountValue(value: unknown): number {
if (value == null) return 0;
if (typeof value === 'number') return Number.isFinite(value) ? value : 0;
@@ -85,11 +74,7 @@ onUnmounted(() => {
<span class="chip-label">{{ t('wallet.cash_balance') }}</span>
<span class="chip-amount">{{ available }}</span>
</span>
<!-- Integrated Avatar image (hidden when hideAvatar is set, e.g. desktop top nav) -->
<span v-if="!props.hideAvatar" class="chip-avatar-wrap">
<img v-if="displayAvatarUrl" :src="displayAvatarUrl" alt="" class="chip-avatar-img" />
</span>
<span class="chevron" :class="{ open }"></span>
</button>
<div v-if="open" class="cash-panel">
@@ -125,15 +110,16 @@ onUnmounted(() => {
.cash-chip {
display: flex;
align-items: center;
gap: 10px;
gap: 4px;
height: 36px;
padding: 0 6px 0 10px;
padding: 0 8px 0 8px;
min-width: 0;
cursor: pointer;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: 6px;
background: var(--bg-card);
box-sizing: border-box;
box-shadow: var(--shadow);
}
.chip-body {
@@ -145,8 +131,8 @@ onUnmounted(() => {
.chip-label {
font-size: 8px;
color: var(--text-dim);
font-weight: 500;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
@@ -157,32 +143,21 @@ onUnmounted(() => {
.chip-amount {
font-size: 11px;
font-weight: 600;
color: var(--text);
font-weight: 800;
color: var(--accent-light);
white-space: nowrap;
line-height: 1.15;
font-variant-numeric: tabular-nums;
}
/* Integrated Avatar Styling */
.chip-avatar-wrap {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border-gold-soft);
flex-shrink: 0;
background: var(--bg-body);
.chevron {
font-size: 10px;
color: var(--primary-light);
transition: transform 0.2s;
margin-left: 0;
}
.chip-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
.chevron.open {
transform: rotate(180deg);
}
.cash-panel {
@@ -192,12 +167,10 @@ onUnmounted(() => {
width: min(260px, 78vw);
padding: 12px 14px;
z-index: 130;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--dropdown-bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: var(--shadow);
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card-elevated);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.cash-panel::before {
@@ -209,9 +182,9 @@ onUnmounted(() => {
justify-content: space-between;
gap: 12px;
font-size: 13px;
font-weight: 500;
font-weight: 600;
padding: 6px 0;
font-variant-numeric: tabular-nums;
color: var(--text);
}
.panel-row.muted {
@@ -219,8 +192,8 @@ onUnmounted(() => {
}
.panel-row.highlight {
color: var(--text);
font-weight: 600;
color: var(--accent-light);
font-weight: 800;
font-size: 14px;
}
@@ -234,18 +207,18 @@ onUnmounted(() => {
width: 100%;
padding: 8px 0;
margin-top: 4px;
border-radius: var(--radius-sm);
border: none;
background: var(--primary);
color: var(--on-primary);
border-radius: 6px;
border: 1px solid var(--primary);
background: transparent;
color: var(--primary-light);
font-size: 13px;
font-weight: 600;
font-weight: 700;
cursor: pointer;
transition: opacity 0.15s ease;
transition: background 0.2s;
}
.panel-recharge-btn:active {
opacity: 0.88;
background: rgba(244, 162, 97, 0.1);
}
.backdrop {

View File

@@ -51,7 +51,7 @@ function onConfirm() {
@click.self="close"
>
<div
class="confirm-modal"
class="confirm-modal glass-card"
role="alertdialog"
aria-modal="true"
:aria-labelledby="title ? 'confirm-dialog-title' : undefined"
@@ -95,26 +95,22 @@ function onConfirm() {
justify-content: center;
padding: 20px;
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
background: rgba(15, 32, 39, 0.42);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.confirm-modal {
width: 100%;
max-width: 340px;
background: var(--dropdown-bg, #0A2540);
border: 1px solid var(--border-strong);
border-radius: 12px;
padding: 22px 18px 16px;
box-shadow: var(--shadow);
}
.confirm-title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 800;
color: var(--text);
color: var(--primary-light);
text-align: center;
line-height: 1.35;
}
@@ -123,7 +119,7 @@ function onConfirm() {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.6;
color: var(--text-muted);
color: var(--text-secondary);
text-align: center;
}
@@ -135,7 +131,7 @@ function onConfirm() {
.confirm-btn {
flex: 1;
min-height: 44px;
border-radius: 8px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 700;
cursor: pointer;
@@ -149,19 +145,21 @@ function onConfirm() {
.confirm-btn.cancel {
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
background: rgba(15, 32, 39, 0.45);
color: var(--text-secondary);
}
.confirm-btn.confirm {
border: none;
background: var(--primary);
color: var(--on-primary);
background: var(--gradient-primary);
color: #FFFFFF;
box-shadow: 0 4px 14px rgba(231, 111, 81, 0.35);
}
.confirm-btn.confirm.danger {
background: rgba(239, 68, 68, 0.85);
background: var(--danger);
color: #fff;
box-shadow: 0 4px 14px rgba(239, 68, 68, 0.35);
}
.confirm-fade-enter-active,

View File

@@ -46,6 +46,7 @@ const iframeSrc = computed(() => {
min-height: 0;
display: flex;
flex-direction: column;
margin: 0 -16px;
background: var(--bg-body);
}
@@ -54,6 +55,6 @@ const iframeSrc = computed(() => {
width: 100%;
min-height: calc(100dvh - 140px);
border: 0;
background: var(--bg-body);
background: #fff;
}
</style>

View File

@@ -216,10 +216,8 @@ onMounted(() => {
position: absolute;
bottom: 64px;
right: 0;
background: var(--dropdown-bg);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--border-gold-soft);
background: #1c3742;
border: 1px solid rgba(244, 162, 97, 0.25);
border-radius: 8px;
padding: 4px;
display: flex;
@@ -244,7 +242,7 @@ onMounted(() => {
bottom: -6px;
right: 22px;
border-width: 6px 6px 0 6px;
border-color: var(--dropdown-bg) transparent transparent transparent;
border-color: #1c3742 transparent transparent transparent;
z-index: 2;
}
@@ -252,7 +250,7 @@ onMounted(() => {
bottom: -7px;
right: 21px;
border-width: 7px 7px 0 7px;
border-color: var(--border-gold-soft) transparent transparent transparent;
border-color: rgba(244, 162, 97, 0.25) transparent transparent transparent;
z-index: 1;
}
@@ -274,8 +272,8 @@ onMounted(() => {
}
.menu-item:hover {
color: var(--text);
background: var(--bg-hover);
color: var(--primary-light);
background: rgba(244, 162, 97, 0.08);
}
.menu-icon {
@@ -367,10 +365,10 @@ onMounted(() => {
width: 380px;
height: 600px;
max-height: calc(100vh - 120px);
background: var(--dropdown-bg);
border: 1px solid var(--border-strong);
background: #1a323c;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -382,9 +380,10 @@ onMounted(() => {
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #162d36;
height: 48px;
flex-shrink: 0;
}
.popup-title {
@@ -399,7 +398,7 @@ onMounted(() => {
.popup-title .tab-badge {
background: var(--primary);
color: var(--on-primary);
color: var(--bg-body);
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
@@ -440,15 +439,18 @@ onMounted(() => {
}
.popup-body {
height: calc(100% - 48px);
position: relative;
background: var(--bg-body);
flex: 1;
min-height: 0;
background: #1a323c;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
.panel-content {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* Transitions */

View File

@@ -1,114 +1,100 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{
size?: number;
progress?: number;
active?: boolean;
}>();
const rotation = computed(() => (props.progress ?? 0) * 360);
const wrapperStyle = computed(() => {
if (props.active) return {};
return {
transform: `rotate(${rotation.value}deg)`,
transition: 'transform 0.05s linear',
};
});
</script>
<template>
<svg
:class="['gold-spinner', { 'is-active': active }]"
:class="['spinner', { 'is-active': active }]"
:width="size ?? 48"
:height="size ?? 48"
viewBox="0 0 48 48"
fill="none"
xmlns="http://www.w3.org/2000/svg"
:style="!active ? wrapperStyle : undefined"
>
<circle cx="24" cy="24" r="20" stroke="rgba(255,255,255,0.14)" stroke-width="3" fill="none" />
<defs>
<linearGradient id="sp-track" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#F4A261" stop-opacity="0.15" />
<stop offset="50%" stop-color="#FFB980" stop-opacity="0.22" />
<stop offset="100%" stop-color="#F4A261" stop-opacity="0.15" />
</linearGradient>
<linearGradient id="sp-arc" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFDDD2" />
<stop offset="22%" stop-color="#FFB980" />
<stop offset="50%" stop-color="#F4A261" />
<stop offset="78%" stop-color="#E76F51" />
<stop offset="100%" stop-color="#F4A261" />
</linearGradient>
<filter id="sp-glow">
<feGaussianBlur stdDeviation="1.2" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<radialGradient id="sp-dot" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#FFDDD2" />
<stop offset="60%" stop-color="#FFB980" />
<stop offset="100%" stop-color="#F4A261" />
</radialGradient>
</defs>
<circle cx="24" cy="24" r="20" stroke="url(#sp-track)" stroke-width="3" fill="none" />
<circle
cx="24"
cy="24"
r="20"
stroke="rgba(255,255,255,0.72)"
stroke="url(#sp-arc)"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-dasharray="31.4 94.2"
filter="url(#sp-glow)"
/>
<circle cx="24" cy="4" r="2.5" fill="url(#sp-dot)" filter="url(#sp-glow)">
<animate
v-if="active"
attributeName="r"
values="2.5;3.2;2.5"
dur="1.2s"
repeatCount="indefinite"
/>
</circle>
<circle v-if="active" cx="36" cy="13.5" r="1.8" fill="#FFDDD2" opacity="0.6">
<animate attributeName="opacity" values="0.6;0.15;0.6" dur="0.9s" repeatCount="indefinite" begin="0.15s" />
</circle>
<circle v-if="active" cx="34" cy="34" r="1.5" fill="#FFB980" opacity="0.4">
<animate attributeName="opacity" values="0.4;0.1;0.4" dur="1.1s" repeatCount="indefinite" begin="0.35s" />
</circle>
<circle v-if="active" cx="14" cy="34" r="1.5" fill="#FFB980" opacity="0.4">
<animate attributeName="opacity" values="0.4;0.1;0.4" dur="1s" repeatCount="indefinite" begin="0.55s" />
</circle>
</svg>
</template>
<style scoped>
.gold-spinner {
.spinner {
display: block;
will-change: transform;
}
.gold-spinner.is-active {
animation: gs-spin 0.8s linear infinite;
.spinner.is-active {
animation: sp-spin 0.8s linear infinite;
}
@keyframes gs-spin {
@keyframes sp-spin {
to { transform: rotate(360deg); }
}
</style>
</style>

View File

@@ -130,7 +130,7 @@ watch(totalPages, () => {
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
background: rgba(201, 162, 39, 0.05);
}
.header-title {
@@ -158,8 +158,8 @@ watch(totalPages, () => {
height: 24px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-hover);
color: var(--primary-light);
background: var(--secondary);
color: var(--gold);
display: flex;
align-items: center;
justify-content: center;
@@ -228,7 +228,7 @@ watch(totalPages, () => {
}
.chevron {
color: var(--primary-light);
color: var(--gold);
font-size: 18px;
font-family: monospace;
}

View File

@@ -49,7 +49,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
</script>
<template>
<section class="league-block" :class="{ expanded }">
<section class="league-block">
<button type="button" class="league-row" :aria-expanded="expanded" @click="emit('toggle')">
<span class="toggle-icon" :class="{ open: expanded }" aria-hidden="true">
<span class="toggle-mark">{{ expanded ? '' : '+' }}</span>
@@ -57,9 +57,9 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
<span class="league-info">
<span class="league-title">*{{ leagueName }}</span>
<span class="league-stats">
<span class="stat-item">{{ totalCount }} {{ t('bet.match_unit') }}</span>
<span v-if="liveCount > 0" class="stat-item stat-live">{{ liveCount }} {{ t('bet.status_live') }}</span>
<span v-if="openCount > 0" class="stat-item stat-open">{{ openCount }} {{ t('bet.status_open') }}</span>
<span class="stat-item">{{ totalCount }}{{ t('bet.match_unit') }}</span>
<span v-if="liveCount > 0" class="stat-item stat-live">{{ liveCount }}{{ t('bet.status_live') }}</span>
<span v-if="openCount > 0" class="stat-item stat-open">{{ openCount }}{{ t('bet.status_open') }}</span>
</span>
</span>
<img
@@ -88,26 +88,11 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
.league-block {
position: relative;
isolation: isolate;
margin-bottom: 12px;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
background: var(--gradient-card);
box-shadow: var(--shadow-card-sm);
margin-bottom: 10px;
border-radius: var(--radius-lg);
overflow: hidden;
}
.league-block::before {
content: '';
position: absolute;
top: 0;
left: 8%;
right: 8%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
z-index: 2;
}
.league-row {
position: relative;
z-index: 1;
@@ -116,7 +101,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
align-items: center;
gap: 10px;
padding: 12px 16px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%);
background: none;
border: none;
border-radius: 0;
text-align: left;
@@ -127,8 +112,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
content: '';
position: absolute;
inset: 0;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
opacity: 0.25;
background: linear-gradient(135deg, rgba(244, 162, 97, 0.02) 0%, transparent 60%);
z-index: -1;
pointer-events: none;
}
@@ -142,17 +126,17 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border);
background: var(--primary);
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.toggle-mark {
color: var(--text-muted);
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
font-size: 17px;
font-weight: 700;
line-height: 1;
margin-top: -1px;
}
@@ -167,7 +151,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
.league-title {
font-size: 13px;
font-weight: 600;
font-weight: 700;
color: var(--text);
line-height: 1.35;
min-width: 0;
@@ -187,7 +171,7 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
}
.stat-open {
color: #6ee78a;
color: var(--warning);
}
.league-saishi {
@@ -202,14 +186,15 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
}
.match-panel {
background: linear-gradient(180deg, rgba(0, 20, 40, 0.35) 0%, rgba(0, 26, 51, 0.2) 100%);
padding: 12px 8px 8px;
padding: 8px 0 4px;
background: var(--bg-card-elevated);
border-top: 1px solid var(--border);
}
.match-grid {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 10px;
gap: 8px;
padding: 0 4px;
}
</style>

View File

@@ -27,6 +27,6 @@ const src = computed(() => `/flags/${countryCode.value}.svg`);
flex-shrink: 0;
border-radius: 2px;
object-fit: cover;
box-shadow: 0 0 0 1px var(--border);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
</style>

View File

@@ -81,7 +81,7 @@ onUnmounted(() => {
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-body);
background: var(--bg-card-elevated);
color: var(--primary-light);
font-size: 11px;
font-weight: 700;
@@ -89,6 +89,7 @@ onUnmounted(() => {
cursor: pointer;
box-sizing: border-box;
flex-shrink: 0;
box-shadow: var(--shadow);
}
.locale-switch.compact .locale-trigger {
@@ -105,12 +106,10 @@ onUnmounted(() => {
margin: 0;
padding: 4px;
list-style: none;
background: var(--dropdown-bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid var(--border-strong);
background: var(--bg-card-elevated);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.locale-option {
@@ -128,7 +127,7 @@ onUnmounted(() => {
.locale-option:hover,
.locale-option.active {
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.1);
color: var(--primary-light);
}
</style>

View File

@@ -26,7 +26,7 @@ function continueBrowsing() {
<Teleport to="body">
<Transition name="fade">
<div v-if="auth.loginPromptVisible" class="login-overlay" @click.self="continueBrowsing">
<div class="login-modal">
<div class="login-modal auth-card">
<button type="button" class="close-btn" :aria-label="t('auth.continue_browsing')" @click="continueBrowsing">
</button>
@@ -34,7 +34,7 @@ function continueBrowsing() {
<p class="login-hint">{{ t('auth.login_hint') }}</p>
<div class="login-actions">
<button type="button" class="login-submit" @click="goLogin">
<button type="button" class="login-submit btn-primary" @click="goLogin">
{{ t('auth.go_login') }}
</button>
<button type="button" class="login-secondary" @click="continueBrowsing">
@@ -55,51 +55,54 @@ function continueBrowsing() {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
padding: 20px;
background: rgba(15, 32, 39, 0.42);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.login-modal {
position: relative;
width: 90%;
max-width: 360px;
background: var(--bg-card);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius);
padding: 28px 24px 24px;
width: 100%;
max-width: 340px;
padding: 28px 22px 22px;
}
.close-btn {
position: absolute;
top: 12px;
right: 14px;
top: 10px;
right: 12px;
background: none;
border: none;
color: var(--text-muted);
font-size: 18px;
color: var(--text-dim);
font-size: 17px;
cursor: pointer;
padding: 4px;
padding: 6px;
line-height: 1;
border-radius: 6px;
transition: color 0.2s, background 0.2s;
}
.close-btn:hover {
color: var(--text);
.close-btn:active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.1);
}
.login-title {
font-size: 18px;
font-size: 19px;
font-weight: 800;
color: var(--primary-light);
margin: 0 0 6px;
margin: 0 0 8px;
text-align: center;
letter-spacing: 0.02em;
}
.login-hint {
font-size: 12px;
color: var(--text-muted);
font-size: 13px;
line-height: 1.45;
color: var(--text-secondary);
text-align: center;
margin: 0 0 24px;
margin: 0 0 22px;
}
.login-actions {
@@ -109,47 +112,48 @@ function continueBrowsing() {
}
.login-submit {
padding: 12px;
border-radius: var(--radius-sm);
border: none;
background: var(--gradient-accent);
color: var(--text);
min-height: 46px;
font-size: 15px;
font-weight: 800;
cursor: pointer;
min-height: 44px;
transition: opacity 0.2s;
box-shadow: none;
}
.login-submit:active {
opacity: 0.85;
}
.login-secondary {
padding: 10px;
padding: 11px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
background: rgba(15, 32, 39, 0.45);
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
min-height: 40px;
transition: color 0.2s, border-color 0.2s;
min-height: 42px;
backdrop-filter: blur(8px);
transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.login-secondary:active {
color: var(--text);
border-color: var(--border-gold-soft);
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.06);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
transition: opacity 0.22s ease;
}
.fade-enter-active .login-modal,
.fade-leave-active .login-modal {
transition: transform 0.22s ease, opacity 0.22s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.fade-enter-from .login-modal,
.fade-leave-to .login-modal {
opacity: 0;
transform: scale(0.96) translateY(8px);
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, getCurrentInstance } from 'vue';
import { ref, computed, getCurrentInstance, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { teamFlagUrl } from '../utils/teamFlag';
@@ -44,6 +44,13 @@ const props = defineProps<{
const emit = defineEmits<{ bet: [id: string] }>();
const { t, locale } = useI18n();
const router = useRouter();
const slip = useBetSlipStore();
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 formatKickoff(startTime: string) {
return formatLocalMatchDateTime(startTime, locale.value, {
@@ -78,22 +85,14 @@ const liveScoreText = computed(() => {
return `${s.ftHome} - ${s.ftAway}`;
});
const router = useRouter();
const slip = useBetSlipStore();
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;
for (const market of props.match.markets ?? []) {
const sel = market.selections?.find((s: any) => s.id === selId);
const sel = market.selections?.find((s: { id: string }) => s.id === selId);
if (!sel) continue;
const code = (sel.selectionCode || '').toUpperCase();
if (code === 'HOME') return 'home';
@@ -102,7 +101,7 @@ function getHoveredSide() {
if (code === 'OVER') return 'home';
if (code === 'UNDER') return 'away';
if (code === 'ODD' || code === 'EVEN') return 'draw';
const selIndex = market.selections?.findIndex((s: any) => s.id === selId) ?? -1;
const selIndex = market.selections?.findIndex((s: { id: string }) => s.id === selId) ?? -1;
if (selIndex === -1) continue;
if (market.marketType === 'FT_1X2') {
if (selIndex === 0) return 'home';
@@ -116,24 +115,39 @@ function getHoveredSide() {
return '';
}
const activeMarketType = ref<string>('');
function pickQuickBetMarkets(markets: any[] | undefined) {
const supported = ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER'];
const byType = new Map<string, any>();
for (const m of markets ?? []) {
if (!supported.includes(m.marketType)) continue;
if (!Array.isArray(m.selections) || m.selections.length === 0) continue;
if (!byType.has(m.marketType)) byType.set(m.marketType, m);
}
return supported.map((type) => byType.get(type)).filter(Boolean);
}
const activeMarketType = ref('');
watch(
() => props.match.id,
() => { activeMarketType.value = ''; },
);
const currentMarketType = computed(() => {
if (activeMarketType.value) return activeMarketType.value;
const available = getAvailableMarketsForMatch(props.match);
if (available.some((m: any) => m.marketType === 'FT_1X2')) {
return 'FT_1X2';
const available = pickQuickBetMarkets(props.match.markets);
if (activeMarketType.value && available.some((m) => m.marketType === activeMarketType.value)) {
return activeMarketType.value;
}
return available[0]?.marketType || 'FT_1X2';
if (available.some((m) => m.marketType === 'FT_1X2')) return 'FT_1X2';
return available[0]?.marketType ?? '';
});
function isSelected(id: string) {
return slip.isInSlip(id);
}
function getAvailableMarketsForMatch(match: any) {
const supported = ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER'];
return (match.markets ?? []).filter((m: any) => supported.includes(m.marketType));
function getAvailableMarketsForMatch(match: typeof props.match) {
return pickQuickBetMarkets(match.markets);
}
function getMarketTabLabel(type: string) {
@@ -143,37 +157,14 @@ function getMarketTabLabel(type: string) {
if (type === 'FT_OVER_UNDER') return t('bet.market_ft_ou');
return type;
})();
return raw.replace(/全场\s*|FT\s*|Penuh\s*/ig, '').trim();
return raw.replace(/全场\s*|FT\s*|Penuh\s*/gi, '').trim();
}
function getSelLabel(sel: any, marketType: string, lineValue?: string | number | null) {
function getSelLabel(sel: { selectionCode?: string; selectionName: string }, _marketType: string, lineValue?: string | number | null) {
return resolveSelectionLabel(t, sel.selectionCode || '', sel.selectionName || '', { lineValue });
}
function handleOddsClick(match: any, market: any, selId: string, event?: MouseEvent) {
if (isMarketLocked(match, market)) return;
const sel = market.selections?.find((s: any) => s.id === selId);
if (!sel || !event) return;
const item = {
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
matchId: match.id,
matchName: `${match.homeTeamName} vs ${match.awayTeamName}`,
marketId: market.id,
marketName: market.marketDisplayName?.trim() || market.marketType || '',
selectionName: getSelLabel(sel, market.marketType, market.lineValue),
odds: parseFloat(sel.odds),
marketType: market.marketType,
lineValue: market.lineValue != null && market.lineValue !== '' ? parseFloat(String(market.lineValue)) : null,
allowSingle: market.allowSingle,
allowParlay: market.allowParlay,
};
openAt(event, item);
}
function isMarketLocked(match: any, market?: { status: string; allowSingle?: boolean; allowParlay?: boolean }) {
function isMarketLocked(match: any, market?: { status?: string; allowSingle?: boolean; allowParlay?: boolean }) {
if (!market) return true;
if (match.bettingOpen === false) return true;
const status = (market.status ?? 'OPEN').toUpperCase();
@@ -183,7 +174,32 @@ function isMarketLocked(match: any, market?: { status: string; allowSingle?: boo
function getActiveMarket() {
const type = currentMarketType.value;
return props.match.markets?.find((m: any) => m.marketType === type);
if (!type) return undefined;
return pickQuickBetMarkets(props.match.markets).find((m) => m.marketType === type);
}
function handleOddsClick(match: any, market: any, selId: string, event?: MouseEvent) {
if (isMarketLocked(match, market)) return;
const sel = market.selections?.find((s: { id: string }) => s.id === selId);
if (!sel || !event) return;
openAt(event, {
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
matchId: match.id,
matchName: `${match.homeTeamName} vs ${match.awayTeamName}`,
marketId: market.id,
marketName: market.marketDisplayName?.trim() || market.marketType || '',
selectionName: getSelLabel(sel, market.marketType, market.lineValue),
odds: parseFloat(sel.odds),
marketType: market.marketType,
lineValue:
market.lineValue != null && market.lineValue !== ''
? parseFloat(String(market.lineValue))
: null,
allowSingle: market.allowSingle,
allowParlay: market.allowParlay,
});
}
function goMatchDetail() {
@@ -200,12 +216,13 @@ function goMatchDetail() {
'no-quick-bet': noQuickBet,
'match-card--engaged': isCardEngaged,
}"
@click="emit('bet', match.id)"
@click="goMatchDetail"
@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" :class="getHoveredSide()">
<div class="team">
@@ -241,18 +258,23 @@ function goMatchDetail() {
</div>
</div>
<!-- 静止状态下注按钮 -->
<button
type="button"
class="bet-btn"
:class="bettingOpen ? 'btn-gold-outline' : 'bet-btn--view'"
@click.stop="emit('bet', match.id)"
@click.stop="goMatchDetail"
>
{{ bettingOpen ? t('bet.place_bet_short') : t('bet.view_match') }}
</button>
<!-- Morphing Quick Bet panel (Desktop Only, disabled when noQuickBet=true) -->
<div v-if="!noQuickBet" class="card-quick-bet" @click.stop>
<div class="quick-bet-tabs" v-if="getAvailableMarketsForMatch(match).length">
<!-- Hover 快速下注区 -->
<div
v-if="!noQuickBet && getAvailableMarketsForMatch(match).length"
class="card-quick-bet"
@click.stop
>
<div class="quick-bet-tabs">
<button
v-for="m in getAvailableMarketsForMatch(match)"
:key="m.marketType"
@@ -263,7 +285,7 @@ function goMatchDetail() {
>
{{ getMarketTabLabel(m.marketType) }}
</button>
<span class="quick-bet-all-link" @click="goMatchDetail">{{ t('bet.all') || '全部' }} </span>
<span class="quick-bet-all-link" @click="goMatchDetail">{{ t('bet.all') }} </span>
</div>
<div class="quick-bet-odds">
@@ -273,13 +295,16 @@ function goMatchDetail() {
compact
:locked="isMarketLocked(match, getActiveMarket())"
:line-value="getActiveMarket()?.lineValue"
:selections="getActiveMarket()?.selections || []"
:selections="(getActiveMarket()?.selections || []).map((s: any) => ({
...s,
odds: String(s.odds),
}))"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(match, getActiveMarket()!, selId, ev)"
/>
</template>
<div v-else class="quick-bet-no-odds">
{{ t('bet.no_market_odds') || '暂无盘口' }}
{{ t('bet.no_market_odds') }}
</div>
</div>
</div>
@@ -289,40 +314,31 @@ function goMatchDetail() {
<style scoped>
.match-card {
position: relative;
background: var(--gradient-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 10px 10px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: var(--radius-lg);
padding: 10px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
gap: 8px;
min-width: 0;
min-height: 130px;
overflow: hidden;
cursor: pointer;
box-shadow: var(--shadow-card-sm);
box-shadow: var(--shadow);
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.match-card::before {
content: '';
position: absolute;
top: 0;
left: 12%;
right: 12%;
height: 1px;
background: var(--gradient-card-shine);
inset: 0;
background: linear-gradient(135deg, rgba(244, 162, 97, 0.03) 0%, transparent 50%, rgba(231, 111, 81, 0.03) 100%);
pointer-events: none;
z-index: 1;
}
.match-card--phase {
background: linear-gradient(
165deg,
rgba(255, 255, 255, 0.07) 0%,
rgba(255, 255, 255, 0.03) 30%,
rgba(0, 20, 40, 0.55) 100%
);
z-index: 0;
}
.teams-row {
@@ -352,14 +368,13 @@ function goMatchDetail() {
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
font-weight: 800;
color: var(--text);
line-height: 1.3;
line-height: 1.2;
word-break: break-word;
text-align: center;
padding: 0 6px;
padding: 0 4px;
align-self: stretch;
letter-spacing: -0.02em;
transition: font-size 0.25s ease;
}
@@ -368,19 +383,18 @@ function goMatchDetail() {
}
.team-flag {
width: 72px;
height: 48px;
width: 56px;
height: 38px;
object-fit: cover;
border-radius: 3px;
display: block;
transition: transform 0.25s ease, opacity 0.2s ease, height 0.25s ease, width 0.25s ease, margin 0.25s ease;
transition: transform 0.25s ease, opacity 0.2s ease, height 0.25s ease, width 0.25s ease;
}
.team-flag.flag-logo {
width: 48px;
height: 48px;
width: 38px;
height: 38px;
object-fit: contain;
transition: transform 0.25s ease, opacity 0.2s ease, height 0.25s ease, width 0.25s ease, margin 0.25s ease;
}
.center-col {
@@ -389,38 +403,28 @@ function goMatchDetail() {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 56px;
gap: 4px;
min-width: 48px;
}
.kickoff {
font-size: 12px;
font-weight: 500;
font-weight: 600;
color: var(--text-muted);
line-height: 1.2;
line-height: 1.15;
white-space: normal;
text-align: center;
max-width: 96px;
max-width: 80px;
overflow-wrap: anywhere;
transition: opacity 0.2s ease, height 0.2s ease, margin 0.2s ease;
}
.live-score {
font-size: 18px;
font-weight: 700;
color: var(--text);
line-height: 1;
letter-spacing: 0.02em;
transition: font-size 0.25s ease;
}
.live-score,
.vs {
font-size: 22px;
font-size: 18px;
font-weight: 900;
color: var(--text);
letter-spacing: 0.08em;
color: var(--primary);
line-height: 1;
transition: font-size 0.25s ease, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.status-tag {
@@ -430,47 +434,52 @@ function goMatchDetail() {
z-index: 3;
font-size: 10px;
font-weight: 700;
padding: 3px 10px;
padding: 2px 8px;
border-radius: 0 6px 0 8px;
line-height: 1.3;
white-space: nowrap;
letter-spacing: 0.02em;
}
.status-tag--open {
background: rgba(255, 255, 255, 0.12);
color: var(--primary-light);
border: 1px solid var(--border-gold-soft);
background: rgba(255, 179, 0, 0.15);
color: #FFB300;
border-bottom: 1px solid rgba(255, 179, 0, 0.3);
border-left: 1px solid rgba(255, 179, 0, 0.3);
}
.status-tag--settled {
background: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
background: var(--bg-elevated);
color: var(--text-dim);
border-bottom: 1px solid var(--border);
border-left: 1px solid var(--border);
}
.status-tag--pending {
background: rgba(255, 179, 0, 0.15);
color: #FFB300;
border-bottom: 1px solid rgba(255, 179, 0, 0.3);
border-left: 1px solid rgba(255, 179, 0, 0.3);
}
.bet-btn {
position: relative;
z-index: 2;
width: auto;
min-width: 80px;
padding: 5px 24px;
min-width: 72px;
padding: 4px 20px;
border-radius: 6px;
font-size: 13px;
letter-spacing: 0.04em;
font-size: 12px;
line-height: 1.2;
overflow: hidden;
transition: opacity 0.2s ease, transform 0.2s ease, height 0.25s ease, padding 0.25s ease, min-width 0.25s ease, min-height 0.25s ease, margin 0.25s ease;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.bet-btn--view {
background: var(--bg-body);
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-muted);
}
/* --- Morphing Quick Bet panel --- */
/* ── Hover 快速下注区 ── */
.card-quick-bet {
position: absolute;
left: 8px;
@@ -488,13 +497,14 @@ function goMatchDetail() {
@media (hover: hover) {
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) {
border-color: var(--border-gold);
box-shadow: var(--shadow-sm);
border-color: var(--border-active);
box-shadow: 0 4px 14px rgba(244, 162, 97, 0.15);
transform: translateY(-1px);
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .teams-row {
transform: translateY(0);
padding: 0 40px;
padding: 0 28px;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team {
@@ -545,11 +555,12 @@ function goMatchDetail() {
}
.match-card.no-quick-bet:hover {
border-color: var(--border-gold);
box-shadow: var(--shadow-sm);
border-color: var(--border-active);
box-shadow: 0 4px 14px rgba(244, 162, 97, 0.12);
}
}
/* ── Quick Bet Tabs ── */
.quick-bet-tabs {
display: flex;
align-items: center;
@@ -570,13 +581,10 @@ function goMatchDetail() {
transition: all 0.15s;
}
.quick-bet-tab-btn:hover {
color: var(--primary-light);
}
.quick-bet-tab-btn:hover,
.quick-bet-tab-btn.active {
color: var(--primary-light);
background: var(--border-gold-soft);
background: rgba(244, 162, 97, 0.1);
}
.quick-bet-all-link {
@@ -586,11 +594,6 @@ function goMatchDetail() {
color: var(--primary-light);
cursor: pointer;
padding: 2px 4px;
transition: opacity 0.15s;
}
.quick-bet-all-link:hover {
opacity: 0.7;
}
.quick-bet-odds {
@@ -603,7 +606,6 @@ function goMatchDetail() {
.quick-bet-odds :deep(.odds-btn) {
min-height: 26px !important;
padding: 1px 4px !important;
gap: 1px !important;
}
.quick-bet-odds :deep(.odds) {
@@ -619,34 +621,37 @@ function goMatchDetail() {
color: var(--text-muted);
}
/* --- VS Selection Highlight: scale selected team --- */
/* ── 悬浮侧队高亮动效 ── */
.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);
filter: brightness(1.08) saturate(1.15);
z-index: 2;
}
.teams-row.home .team:last-child {
opacity: 0.35 !important;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
filter: grayscale(0.6) brightness(0.5);
}
.teams-row.away .team:last-child {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
filter: brightness(1.08) saturate(1.15);
z-index: 2;
}
.teams-row.away .team:first-child {
opacity: 0.35 !important;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
filter: grayscale(0.6) brightness(0.5);
}
.teams-row.draw .team {
transform: scale(1.05);
filter: brightness(1.15) saturate(1.1);
filter: brightness(1.08) saturate(1.1);
}
</style>

View File

@@ -9,6 +9,10 @@ import { useAuthStore } from '../stores/auth';
import { stripHtml } from '../utils/html';
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
const emit = defineEmits<{
'click-message': [id: string];
}>();
const router = useRouter();
const { t, locale } = useI18n();
const auth = useAuthStore();
@@ -50,7 +54,7 @@ function messagePreview(item: PlayerMessage) {
}
function openDetail(id: string) {
router.push(`/messages/${id}`);
emit('click-message', id);
}
async function fetchPage(nextPage: number, append = false) {
@@ -194,10 +198,10 @@ onActivated(tryLoad);
.login-link {
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-sm);
padding: 8px 14px;
background: var(--surface-active);
color: var(--text);
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
font-size: 13px;
font-weight: 600;
}
@@ -205,6 +209,7 @@ onActivated(tryLoad);
.list {
display: flex;
flex-direction: column;
padding: 0 12px;
}
.list-row {
@@ -220,7 +225,7 @@ onActivated(tryLoad);
align-items: center;
gap: 10px;
min-width: 0;
padding: 14px 0;
padding: 14px 4px 14px 0;
border: none;
background: none;
text-align: left;
@@ -228,7 +233,7 @@ onActivated(tryLoad);
}
.list-row.unread .title {
color: #fff;
color: var(--text);
font-weight: 700;
}
@@ -251,7 +256,7 @@ onActivated(tryLoad);
flex: 1;
min-width: 0;
font-size: 15px;
color: var(--text-muted);
color: var(--text-secondary);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
@@ -269,8 +274,8 @@ onActivated(tryLoad);
}
.status-badge.unread {
color: var(--text);
background: var(--surface-active);
color: var(--primary);
background: rgba(244, 162, 97, 0.12);
}
.preview {
@@ -307,7 +312,7 @@ onActivated(tryLoad);
.delete-btn:active:not(:disabled) {
background: rgba(239, 68, 68, 0.12);
color: rgba(239, 68, 68, 0.95);
color: var(--danger);
}
.delete-btn svg {
@@ -325,9 +330,9 @@ onActivated(tryLoad);
width: 100%;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-sm);
background: var(--bg-card);
color: var(--text);
color: var(--primary);
font-size: 13px;
}
</style>

View File

@@ -1,284 +0,0 @@
<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(255,255,255,${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(255,255,255,${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(255,255,255,${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>

View File

@@ -131,13 +131,14 @@ onUnmounted(() => {
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-body);
background: var(--bg-input);
color: var(--text);
font-family: inherit;
font-size: 12px;
line-height: 1;
cursor: pointer;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.country-trigger:active {
@@ -166,12 +167,10 @@ onUnmounted(() => {
z-index: 60;
width: min(260px, calc(100vw - 48px));
padding: 4px;
background: var(--dropdown-bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid var(--border-strong);
border-radius: 6px;
box-shadow: var(--shadow);
background: var(--bg-card-elevated);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.country-search {
@@ -180,19 +179,19 @@ onUnmounted(() => {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-body);
background: var(--bg-input);
color: var(--text);
font-size: 12px;
font-family: inherit;
}
.country-search::placeholder {
color: var(--neutral);
color: var(--text-muted);
}
.country-search:focus {
outline: none;
border-color: var(--border-gold-soft);
border-color: var(--primary);
}
.country-menu {
@@ -215,7 +214,7 @@ onUnmounted(() => {
.country-option:hover,
.country-option.active {
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
}
.country-option .country-iso {

View File

@@ -55,7 +55,7 @@ function confirm() {
position: fixed;
inset: 0;
z-index: 210;
background: rgba(0, 0, 0, 0.72);
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
@@ -69,11 +69,11 @@ function confirm() {
max-width: 360px;
max-height: 85vh;
overflow-y: auto;
background: var(--gradient-card);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius);
background: var(--bg-card-elevated);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 14px 14px;
box-shadow: var(--shadow), var(--glow-gold);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.close-x {
@@ -84,7 +84,7 @@ function confirm() {
height: 28px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--bg-body);
background: var(--bg-elevated);
color: var(--text-muted);
font-size: 12px;
line-height: 1;
@@ -95,7 +95,7 @@ function confirm() {
margin: 0 28px 12px 0;
font-size: 15px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
}
.actions {
@@ -115,11 +115,13 @@ function confirm() {
.btn-cancel {
border: 1px solid var(--border);
background: var(--bg-body);
background: var(--bg-card);
color: var(--text-muted);
}
.btn-confirm {
border: none;
background: var(--gradient-primary);
color: #FFFFFF;
}
</style>

View File

@@ -88,14 +88,14 @@ function select(key: string) {
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-body);
background: var(--bg-input);
color: var(--text);
font-size: 13px;
}
.picker-search:focus {
outline: none;
border-color: var(--border-gold-soft);
border-color: var(--primary);
}
.picker-grid {
@@ -115,15 +115,15 @@ function select(key: string) {
padding: 8px 4px 6px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-body);
background: var(--bg-card);
cursor: pointer;
min-width: 0;
}
.picker-item.active {
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35);
background: var(--surface-accent);
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(244, 162, 97, 0.18);
background: rgba(244, 162, 97, 0.04);
}
.picker-photo {
@@ -132,7 +132,7 @@ function select(key: string) {
border-radius: 50%;
object-fit: cover;
object-position: top;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid var(--border);
}
.picker-name {

View File

@@ -48,8 +48,8 @@ function drawCaptcha() {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bg = cssVar('--captcha-canvas-bg', '#0d0d0d');
const charBase = cssVar('--captcha-char', '#f0d875');
const bg = cssVar('--captcha-canvas-bg', '#162D36');
const charBase = cssVar('--captcha-char', '#F4A261');
const noiseRgb = cssVar('--captcha-noise-rgb', '255, 255, 255');
ctx.fillStyle = bg;
@@ -79,7 +79,7 @@ function drawCaptcha() {
ctx.translate(x, y);
ctx.rotate((Math.random() - 0.5) * 0.35);
ctx.font = `bold ${15 + Math.random() * 4}px 'Courier New', monospace`;
ctx.fillStyle = withAlpha(charBase, 0.85 + Math.random() * 0.15);
ctx.fillStyle = `rgba(240, 216, 117, ${0.85 + Math.random() * 0.15})`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(code.value[i], 0, 0);
@@ -161,13 +161,13 @@ defineExpose({ validate, refresh });
align-items: stretch;
height: 36px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-body);
border-radius: 8px;
background: var(--bg-input);
overflow: hidden;
}
.captcha-row:focus-within {
border-color: var(--border-gold-soft);
border-color: var(--primary);
}
.hp-field {
@@ -195,7 +195,7 @@ defineExpose({ validate, refresh });
}
.captcha-input::placeholder {
color: var(--neutral);
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;

View File

@@ -1,517 +0,0 @@
<script setup lang="ts">
import { ref, watch, onUnmounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import LocaleFlag from './LocaleFlag.vue';
const props = defineProps<{
modelValue: boolean;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
const { t, locale } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale } = useAppLocale();
const showRulesModal = ref(false);
const showLanguageModal = ref(false);
function close() {
emit('update:modelValue', false);
}
async function changeLocale(code: string) {
await setLocale(code);
showLanguageModal.value = false;
}
function goEdit() {
close();
router.push('/profile/edit');
}
function logout() {
close();
auth.logout();
router.push('/');
}
const isAnyOpen = computed(() => props.modelValue || showRulesModal.value || showLanguageModal.value);
watch(
isAnyOpen,
(isOpen) => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}
);
onUnmounted(() => {
document.body.style.overflow = '';
});
</script>
<template>
<div class="settings-drawer-wrapper" :class="{ open: modelValue }">
<div class="settings-drawer-backdrop" @click="close" />
<div class="settings-drawer-panel">
<!-- Header -->
<div class="drawer-header">
<h2 class="drawer-title">{{ t('profile.settings') }}</h2>
<button type="button" class="drawer-close-btn" @click="close" :aria-label="t('common.cancel')">
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="drawer-content">
<section class="settings-group">
<!-- Modify Profile -->
<button type="button" class="settings-cell" @click="goEdit">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
</svg>
<span class="cell-label">{{ t('profile.edit') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</button>
<!-- Betting Rules -->
<button type="button" class="settings-cell" @click="showRulesModal = true">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M7 4h10v16H7z" />
<path d="M10 8h6M10 12h6M10 16h4" />
</svg>
<span class="cell-label">{{ t('profile.rules_title') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</button>
<!-- Language Selector -->
<button type="button" class="settings-cell" @click="showLanguageModal = true">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c2.5 2.8 4 6 4 9s-1.5 6.2-4 9M12 3c-2.5 2.8-4 6-4 9s1.5 6.2 4 9" />
</svg>
<span class="cell-label">{{ t('profile.language') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</button>
</section>
<!-- Logout Button -->
<button type="button" class="logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</div>
</div>
</div>
<!-- Betting Rules Modal -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="showRulesModal" class="modal-overlay" @click.self="showRulesModal = false">
<div class="modal-card">
<div class="modal-header">
<h3 class="modal-title">{{ t('profile.rules_title') }}</h3>
<button type="button" class="modal-close-btn" @click="showRulesModal = false" :aria-label="t('common.cancel')">
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body rules-modal-body">
<p>{{ t('profile.rules_p1') }}</p>
<p>{{ t('profile.rules_p2') }}</p>
<p>{{ t('profile.rules_p3') }}</p>
<p>{{ t('profile.rules_p4') }}</p>
<p>{{ t('profile.rules_p5') }}</p>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- Language Selector Modal -->
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="showLanguageModal" class="modal-overlay" @click.self="showLanguageModal = false">
<div class="modal-card modal-card--small">
<div class="modal-header">
<h3 class="modal-title">{{ t('profile.language') }}</h3>
<button type="button" class="modal-close-btn" @click="showLanguageModal = false" :aria-label="t('common.cancel')">
<svg class="close-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="modal-body">
<div class="lang-list">
<button
v-for="item in locales"
:key="item.code"
type="button"
class="lang-item"
:class="{ active: locale === item.code }"
@click="changeLocale(item.code)"
>
<div class="lang-item-main">
<LocaleFlag :locale="item.code" :size="16" />
<span>{{ item.label }}</span>
</div>
<svg v-if="locale === item.code" class="check-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20 6 9 17 4 12" />
</svg>
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.settings-drawer-wrapper {
position: fixed;
inset: 0;
z-index: 150;
pointer-events: none;
visibility: hidden;
transition: visibility 0.3s ease;
}
.settings-drawer-wrapper.open {
pointer-events: auto;
visibility: visible;
}
.settings-drawer-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
opacity: 0;
transition: opacity 0.3s ease;
}
.settings-drawer-wrapper.open .settings-drawer-backdrop {
opacity: 1;
}
.settings-drawer-panel {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 70%;
background: #001a33;
border-left: 1px solid var(--border);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
}
.settings-drawer-wrapper.open .settings-drawer-panel {
transform: translateX(0);
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: var(--dropdown-bg);
}
.drawer-title {
font-size: 16px;
font-weight: 700;
color: var(--text);
margin: 0;
}
.drawer-close-btn {
background: transparent;
color: var(--text-muted);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.2s, color 0.2s;
padding: 0;
cursor: pointer;
}
.drawer-close-btn:active {
background: var(--surface-active);
color: var(--text);
}
.close-icon {
width: 20px;
height: 20px;
}
.drawer-content {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 20px;
}
.settings-group {
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-card-sm);
margin-bottom: 24px;
}
.settings-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 48px;
padding: 0 16px;
background: none;
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
border: none;
border-bottom: 1px solid var(--border);
text-align: left;
cursor: pointer;
}
.settings-cell:last-child {
border-bottom: none;
}
.settings-cell:active {
background: var(--bg-hover);
}
.cell-main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.cell-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-muted);
}
.cell-label {
color: var(--text);
}
.cell-chevron {
color: var(--text-muted);
font-size: 20px;
line-height: 1;
font-weight: 300;
transition: transform 0.15s ease;
}
.logout-btn {
width: 100%;
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--danger);
font-size: 14px;
font-weight: 700;
transition: background 0.2s;
cursor: pointer;
}
.logout-btn:active {
background: rgba(255, 69, 58, 0.08);
}
/* Modal Overlays & Cards */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal-card {
width: 100%;
max-width: 340px;
background: var(--dropdown-bg, #0A2540);
border: 1px solid var(--border-strong);
border-radius: 12px;
padding: 20px 18px 22px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-card--small {
max-width: 300px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.modal-title {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--text);
}
.modal-close-btn {
background: transparent;
color: var(--text-muted);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
padding: 0;
transition: background 0.2s, color 0.2s;
cursor: pointer;
}
.modal-close-btn:active {
background: var(--surface-active);
color: var(--text);
}
.modal-body {
color: var(--text);
}
.rules-modal-body {
font-size: 13px;
line-height: 1.6;
color: var(--text-muted);
overflow-y: auto;
max-height: 50vh;
padding-right: 4px;
}
.rules-modal-body p {
margin: 0 0 10px;
}
.rules-modal-body p:last-child {
margin-bottom: 0;
}
/* Language List inside Modal */
.lang-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.lang-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: rgba(255, 255, 255, 0.02);
border-radius: 8px;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.lang-item:active {
background: var(--surface-active);
}
.lang-item.active {
border-color: var(--primary);
color: var(--text);
background: var(--surface-accent);
}
.lang-item-main {
display: flex;
align-items: center;
gap: 10px;
}
.check-icon {
width: 16px;
height: 16px;
color: var(--success, #34C759);
}
/* Modal Transitions */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.2s ease;
}
.modal-fade-enter-active .modal-card,
.modal-fade-leave-active .modal-card {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-from .modal-card,
.modal-fade-leave-to .modal-card {
transform: scale(0.96);
}
</style>

View File

@@ -23,7 +23,7 @@ withDefaults(
letter-spacing: 0.06em;
white-space: nowrap;
pointer-events: none;
opacity: 0.2;
opacity: 0.15;
max-width: 92%;
overflow: hidden;
text-overflow: ellipsis;
@@ -43,11 +43,11 @@ withDefaults(
}
.status-watermark.won {
color: var(--success);
color: rgba(16, 185, 129, 0.12);
}
.status-watermark.lost {
color: var(--danger);
color: rgba(239, 68, 68, 0.12);
}
.status-watermark.push {
@@ -55,14 +55,14 @@ withDefaults(
}
.status-watermark.pending {
color: var(--primary-light);
color: rgba(255, 179, 0, 0.12);
}
.status-watermark.closed {
color: var(--primary);
color: var(--primary-light);
}
.status-watermark.settled {
color: var(--text-muted);
color: var(--primary);
}
</style>

View File

@@ -7,7 +7,7 @@ const props = withDefaults(
teamCode?: string;
teamName?: string;
logoUrl?: string | null;
size?: 'sm' | 'md' | 'lg' | 'xl';
size?: 'sm' | 'md' | 'lg';
}>(),
{ size: 'md' },
);
@@ -62,7 +62,7 @@ watch(
/* 队徽(非国旗):圆角 + 投影 */
.team-emblem--logo {
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.team-emblem--sm {
@@ -80,11 +80,6 @@ watch(
height: 52px;
}
.team-emblem--xl {
width: 72px;
height: 72px;
}
/* 国旗:横向比例 + 铺满 */
.team-emblem:not(.team-emblem--logo) {
object-fit: cover;
@@ -107,11 +102,6 @@ watch(
height: 36px;
}
.team-emblem--xl:not(.team-emblem--logo) {
width: 80px;
height: 54px;
}
/* 队徽:正方形容器 + 完整显示 */
.team-emblem--logo {
object-fit: contain;
@@ -139,8 +129,4 @@ watch(
.team-emblem--lg.team-emblem--placeholder {
font-size: 22px;
}
.team-emblem--xl.team-emblem--placeholder {
font-size: 30px;
}
</style>

View File

@@ -85,8 +85,8 @@ function logout() {
height: 36px;
box-sizing: border-box;
border-radius: 50%;
border: 1px solid var(--border-gold-soft);
background: var(--bg-card);
border: 2px solid var(--primary-light);
background: linear-gradient(145deg, var(--primary-dark), var(--primary-light));
display: flex;
align-items: center;
justify-content: center;
@@ -94,6 +94,7 @@ function logout() {
flex-shrink: 0;
overflow: hidden;
padding: 0;
box-shadow: 0 0 12px rgba(244, 162, 97, 0.3);
}
.avatar-img {
@@ -106,7 +107,7 @@ function logout() {
.avatar-letter {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
color: #FFFFFF;
}
.avatar-menu {
@@ -115,12 +116,12 @@ function logout() {
right: 0;
min-width: 168px;
padding: 8px 0;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--dropdown-bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
box-shadow: var(--shadow);
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card-elevated);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 140;
}
@@ -144,7 +145,7 @@ function logout() {
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.08);
background: var(--bg-hover);
}
.menu-item.recharge {

View File

@@ -1,56 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
size?: 'sm' | 'md' | 'lg';
}>(),
{
size: 'md',
},
);
const sizeClass = computed(() => `vs-badge--${props.size}`);
</script>
<template>
<span
class="vs-badge"
:class="sizeClass"
role="img"
aria-label="VS"
>
VS
</span>
</template>
<style scoped>
.vs-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
font-weight: 700;
font-variant: small-caps;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.88);
line-height: 1;
user-select: none;
}
.vs-badge--sm {
font-size: 11px;
letter-spacing: 0.11em;
}
.vs-badge--md {
font-size: 13px;
letter-spacing: 0.12em;
}
.vs-badge--lg {
font-size: 22px;
letter-spacing: 0.14em;
}
</style>

View File

@@ -1,171 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatMoney } from '../utils/localeDisplay';
const props = defineProps<{
availableBalance: unknown;
cashbackTotal?: string;
frozenBalance?: unknown;
compact?: boolean;
}>();
const { t, locale } = useI18n();
const balanceDisplay = computed(() => formatMoney(props.availableBalance, locale.value));
const balanceSizeClass = computed(() => {
const len = balanceDisplay.value.length;
if (len <= 10) return 'wallet-balance--xl';
if (len <= 13) return 'wallet-balance--lg';
if (len <= 16) return 'wallet-balance--md';
if (len <= 19) return 'wallet-balance--sm';
return 'wallet-balance--xs';
});
</script>
<template>
<section class="wallet-card" :class="{ 'wallet-card--compact': compact }">
<div class="wallet-card-head">
<span class="wallet-kicker">{{ t('wallet.cash_balance') }}</span>
<RouterLink to="/wallet/recharge" class="wallet-recharge">
{{ t('recharge.title') }}
</RouterLink>
</div>
<p class="wallet-balance" :class="balanceSizeClass">{{ balanceDisplay }}</p>
<div class="wallet-metrics">
<div class="wallet-metric">
<span class="wallet-metric-label">{{ t('wallet.stats_cashback') }}</span>
<span class="wallet-metric-value">{{ formatMoney(cashbackTotal ?? '0', locale) }}</span>
</div>
<div class="wallet-metric">
<span class="wallet-metric-label">{{ t('wallet.unsettled') }}</span>
<span class="wallet-metric-value">{{ formatMoney(frozenBalance, locale) }}</span>
</div>
</div>
</section>
</template>
<style scoped>
.wallet-card {
position: relative;
margin-bottom: var(--space-section);
padding: 14px 16px;
border-radius: var(--radius);
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--gradient-card);
box-shadow: var(--shadow-card);
overflow: hidden;
}
.wallet-card::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
}
.wallet-card--compact {
padding: 12px 14px;
}
.wallet-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.wallet-kicker {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: -0.01em;
}
.wallet-recharge {
flex-shrink: 0;
padding: 6px 12px;
border-radius: var(--radius-sm);
background: var(--primary);
color: var(--on-primary);
font-size: 12px;
font-weight: 600;
text-decoration: none;
transition: opacity 0.15s ease;
}
.wallet-recharge:active {
opacity: 0.88;
}
.wallet-balance {
margin: 0 0 10px;
font-size: 34px;
font-weight: 600;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
color: var(--text);
line-height: 1.08;
}
.wallet-balance--xl { font-size: 34px; }
.wallet-balance--lg { font-size: 30px; }
.wallet-balance--md { font-size: 26px; }
.wallet-balance--sm { font-size: 22px; }
.wallet-balance--xs { font-size: 19px; }
.wallet-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
padding-top: 10px;
border-top: 1px solid var(--border);
}
.wallet-metric {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: 0 6px;
}
.wallet-metric:not(:first-child) {
border-left: 1px solid var(--border);
}
.wallet-metric:first-child {
padding-left: 0;
}
.wallet-metric:last-child {
padding-right: 0;
}
.wallet-metric-label {
font-size: 11px;
font-weight: 500;
color: var(--text-dim);
letter-spacing: 0;
}
.wallet-metric-value {
font-size: 13px;
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.01em;
}
</style>

View File

@@ -1,264 +1,127 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
interface Transaction {
transactionType: string;
amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
transactionId?: string;
}
const props = withDefaults(defineProps<{
items: Transaction[];
cashbackTotal?: string;
}>(), {
cashbackTotal: '0',
});
const { t, locale } = useI18n();
const CASHBACK_TYPES = new Set(['CASHBACK', 'CASHBACK_DEPOSIT']);
const stats = computed(() => {
let income = 0;
let expense = 0;
let cashback = 0;
for (const tx of props.items) {
const amt = parseAmount(tx.amount);
const isCb = CASHBACK_TYPES.has(tx.transactionType.toUpperCase());
if (isCb) {
cashback += Math.abs(amt);
}
if (amt >= 0) income += amt;
else expense += Math.abs(amt);
}
// Use server-side cashback total if provided (more accurate across all pages)
if (props.cashbackTotal !== '0') {
cashback = Math.abs(parseAmount(props.cashbackTotal));
}
const net = income - expense;
return { income, expense, net, cashback };
});
</script>
<template>
<div class="wallet-stats-panel">
<div class="stats-grid">
<div class="stat-cell">
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">{{ t('wallet.stats_income') }}</span>
<span class="stat-val income">{{ formatMoneyCompact(stats.income, locale) }}</span>
</div>
<div class="stat-cell">
<div class="stat-item">
<span class="stat-label">{{ t('wallet.stats_expense') }}</span>
<span class="stat-val expense">{{ formatMoneyCompact(stats.expense, locale) }}</span>
</div>
<div class="stat-cell">
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'muted'">
{{ formatMoneyCompact(stats.net, locale) }}
</span>
</div>
<div class="stat-cell">
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
<span class="stat-val">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
</div>
</div>
<div class="stats-divider" />
<div class="stats-row">
<div class="stat-item">
<span class="stat-label">{{ t('wallet.stats_net') }}</span>
<span class="stat-val" :class="stats.net >= 0 ? 'income' : 'expense'">
{{ formatMoneyCompact(stats.net, locale) }}
</span>
</div>
<div class="stat-item">
<span class="stat-label">{{ t('wallet.stats_cashback') }}</span>
<span class="stat-val cashback">{{ formatMoneyCompact(stats.cashback, locale) }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.wallet-stats-panel {
position: relative;
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
padding: 14px 16px;
margin-bottom: var(--space-section);
box-shadow: var(--shadow-card-sm);
overflow: hidden;
border-radius: var(--radius-lg);
padding: 12px;
margin-bottom: 12px;
}
.wallet-stats-panel::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
}
.stat-cell {
.stats-row {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
align-items: center;
}
.stat-item {
flex: 1;
text-align: center;
min-width: 0;
padding: 4px 8px;
}
.stat-label {
font-size: 12px;
font-weight: 500;
color: var(--text-dim);
letter-spacing: 0;
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.03em;
margin-bottom: 2px;
}
.stat-val {
font-size: 15px;
font-weight: 600;
display: block;
font-size: 14px;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
letter-spacing: -0.02em;
}
.stat-val.income { color: var(--success); }
.stat-val.expense { color: var(--danger); }
.stat-val.cashback { color: var(--warning); }
.stat-val.expense { color: var(--text-muted); }
.stat-val.muted { color: var(--text-muted); }
.stats-divider {
height: 1px;
margin: 6px 8px;
background: var(--border);
}
</style>

View File

@@ -99,7 +99,7 @@ const menuItems = computed(() => {
.nav-item.active {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
}
.nav-item.active .dot {
@@ -111,7 +111,7 @@ const menuItems = computed(() => {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
background: var(--tertiary);
transition: all 0.2s;
}

View File

@@ -8,8 +8,8 @@ import { useAuthStore } from '../../stores/auth';
import { formatMoney, parseAmount } from '../../utils/localeDisplay';
import api from '../../api';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import BetSuccessOverlay from '../BetSuccessOverlay.vue';
import ConfirmDialog from '../ConfirmDialog.vue';
import { useAppToast } from '../../composables/useAppToast';
import GoldSpinner from '../GoldSpinner.vue';
import { type BetHistoryItem } from '../BetHistoryCard.vue';
import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage';
@@ -24,6 +24,7 @@ const slip = useBetSlipStore();
const auth = useAuthStore();
const { refreshProfile } = usePlayerProfile();
const { requestLocate } = useDesktopBetLocate();
const { showToast } = useAppToast();
const activeTab = ref<PanelTab>('single');
const transitionName = ref('list-slide');
@@ -38,8 +39,6 @@ const loading = ref(false);
const balanceLoading = ref(false);
const balance = ref<number | null>(null);
const error = ref('');
const success = ref('');
const showSuccess = ref(false);
const showPlaceConfirm = ref(false);
const placeConfirmMessage = ref('');
const pendingSingleItem = ref<SlipItem | null>(null);
@@ -640,7 +639,6 @@ function onPlaceBetClick() {
async function executePlaceSingleItem(item: SlipItem) {
loading.value = true;
error.value = '';
success.value = '';
try {
await api.post('/player/bets/single', {
@@ -650,12 +648,9 @@ async function executePlaceSingleItem(item: SlipItem) {
requestId: genId(),
});
slip.removeItem(item.selectionId);
success.value = t('bet.place_success');
showSuccess.value = true;
await Promise.all([loadBalance(), refreshProfile()]);
setTimeout(() => {
if (showSuccess.value) onSuccessDone();
}, 2200);
showToast(t('bet.place_success'));
syncStakeInputFromSlip();
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
@@ -668,7 +663,6 @@ async function executePlaceSingleItem(item: SlipItem) {
async function executePlaceBet() {
loading.value = true;
error.value = '';
success.value = '';
try {
if (activeTab.value === 'parlay') {
@@ -692,15 +686,12 @@ async function executePlaceBet() {
}
slip.clearSingle();
}
success.value = t('bet.place_success');
showSuccess.value = true;
if (activeTab.value === 'history') {
void loadHistory(true);
}
await Promise.all([loadBalance(), refreshProfile()]);
setTimeout(() => {
if (showSuccess.value) onSuccessDone();
}, 2200);
showToast(t('bet.place_success'));
syncStakeInputFromSlip();
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
@@ -721,13 +712,6 @@ async function confirmPlaceBet() {
await executePlaceBet();
}
function onSuccessDone() {
showSuccess.value = false;
error.value = '';
success.value = '';
syncStakeInputFromSlip();
}
async function loadBalance() {
if (!auth.token) {
balance.value = null;
@@ -1125,7 +1109,6 @@ onUnmounted(() => {
</div>
<p v-if="error" class="panel-error-msg">{{ error }}</p>
<p v-if="success" class="panel-success-msg">{{ success }}</p>
</template>
</div>
@@ -1170,9 +1153,6 @@ onUnmounted(() => {
:loading="loading"
@confirm="confirmPlaceBet"
/>
<!-- Success Overlay -->
<BetSuccessOverlay :show="showSuccess" @done="onSuccessDone" />
</div>
</template>
@@ -1240,7 +1220,7 @@ onUnmounted(() => {
.clear-slip-btn:hover {
color: var(--primary-light);
border-color: var(--border-gold-soft);
background: rgba(255, 255, 255, 0.06);
background: rgba(244, 162, 97, 0.06);
}
.balance-info {
@@ -1298,7 +1278,7 @@ onUnmounted(() => {
.badge {
background: var(--primary);
color: #001A33;
color: #fff;
font-size: 9px;
font-weight: 800;
padding: 1px 4px;
@@ -1307,7 +1287,7 @@ onUnmounted(() => {
}
.odds-warning {
background: rgba(255, 255, 255, 0.12);
background: rgba(244, 162, 97, 0.12);
border-bottom: 1px solid var(--border-gold-soft);
color: var(--primary-light);
font-size: 11px;
@@ -1350,7 +1330,7 @@ onUnmounted(() => {
}
.slip-card {
background: #0A2540;
background: rgba(22, 45, 54, 0.4);
border: 1px solid var(--border);
border-radius: 4px;
padding: 7px 8px;
@@ -1498,7 +1478,7 @@ onUnmounted(() => {
flex: 1;
display: flex;
align-items: center;
background: #002244;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 6px;
@@ -1563,32 +1543,32 @@ onUnmounted(() => {
}
.card-single-btn {
border: 1px solid rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(244, 162, 97, 0.55);
background: rgba(244, 162, 97, 0.14);
color: var(--primary-light);
}
.card-single-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.22);
background: rgba(244, 162, 97, 0.22);
border-color: var(--border-gold);
color: #fff;
}
.card-parlay-btn {
border: 1px dashed rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.04);
border: 1px dashed rgba(244, 162, 97, 0.45);
background: rgba(244, 162, 97, 0.04);
color: var(--primary-light);
}
.card-parlay-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
background: rgba(244, 162, 97, 0.1);
border-color: var(--border-gold-soft);
color: #fff;
}
.card-parlay-btn.active {
border-style: solid;
border-color: rgba(255, 255, 255, 0.35);
border-color: rgba(244, 162, 97, 0.35);
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
}
@@ -1605,7 +1585,7 @@ onUnmounted(() => {
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
background: rgba(244, 162, 97, 0.16);
font-size: 11px;
line-height: 1;
font-weight: 800;
@@ -1635,7 +1615,7 @@ onUnmounted(() => {
.panel-warning {
font-size: 11px;
color: var(--primary-light);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
border: 1px solid var(--border-gold-soft);
border-radius: 4px;
padding: 8px;
@@ -1649,7 +1629,7 @@ onUnmounted(() => {
font-weight: 700;
color: var(--text-muted);
padding: 4px 6px;
background: #0A2540;
background: var(--bg-elevated);
border-radius: 4px;
}
@@ -1658,7 +1638,7 @@ onUnmounted(() => {
}
.stake-control {
background: #0A2540;
background: var(--bg-elevated);
border-radius: 4px;
padding: 10px;
display: flex;
@@ -1669,7 +1649,7 @@ onUnmounted(() => {
.stake-input-box {
display: flex;
align-items: center;
background: #002244;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 10px;
@@ -1713,7 +1693,7 @@ onUnmounted(() => {
.chip-btn {
flex: 1;
background: #0A2540;
background: rgba(22, 45, 54, 0.45); border: 1px solid var(--border);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
@@ -1735,17 +1715,10 @@ onUnmounted(() => {
margin-top: 8px;
}
.panel-success-msg {
color: #2ecc71;
font-size: 11px;
font-weight: 600;
margin-top: 8px;
}
.panel-bottom-fixed {
flex-shrink: 0;
border-top: 1px solid var(--border);
background: rgba(10, 37, 64, 0.98);
background: transparent;
}
.panel-summary {
@@ -1753,7 +1726,6 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
gap: 6px;
border-bottom: 1px solid var(--border);
}
.summary-row {
@@ -1798,7 +1770,7 @@ onUnmounted(() => {
}
.add-parlay-btn:hover {
background: rgba(255, 255, 255, 0.05);
background: rgba(244, 162, 97, 0.05);
border-color: var(--primary);
}
@@ -1807,8 +1779,8 @@ onUnmounted(() => {
min-width: 0;
padding: 10px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
color: #3D2800;
background: var(--gradient-primary);
color: #fff;
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
@@ -1829,7 +1801,7 @@ onUnmounted(() => {
padding: 10px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 800;
@@ -1839,7 +1811,7 @@ onUnmounted(() => {
}
.history-view-all-btn:hover {
background: rgba(255, 255, 255, 0.14);
background: rgba(244, 162, 97, 0.14);
border-color: var(--primary);
}
@@ -1869,7 +1841,7 @@ onUnmounted(() => {
.scope-chip.active {
color: var(--primary-light);
border-color: var(--border-gold-soft);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
}
.history-loading {
@@ -1889,7 +1861,7 @@ onUnmounted(() => {
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: #0A2540;
background: rgba(22, 45, 54, 0.45); border: 1px solid var(--border);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, background 0.15s;
@@ -1897,7 +1869,7 @@ onUnmounted(() => {
.history-card:hover {
border-color: var(--border-gold-soft);
background: rgba(255, 255, 255, 0.04);
background: rgba(244, 162, 97, 0.04);
}
.history-card-top {
@@ -1941,7 +1913,7 @@ onUnmounted(() => {
.history-status.hist-pending {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.1);
background: rgba(244, 162, 97, 0.1);
}
.history-match {

View File

@@ -60,7 +60,7 @@ const { t } = useI18n();
.data-table-container {
width: 100%;
overflow-x: auto;
background: rgba(12, 40, 66, 0.95);
background: var(--bg-card); backdrop-filter: blur(10px);
border: 1px solid var(--border);
border-radius: 6px;
}
@@ -73,7 +73,7 @@ const { t } = useI18n();
}
th {
background: #0A2540;
background: var(--bg-elevated);
color: var(--text-muted);
font-weight: 800;
text-transform: uppercase;

View File

@@ -2,7 +2,6 @@
/**
* PC auth shell — admin-style card: large logo left, form slot right.
*/
import MouseRippleGrid from '../MouseRippleGrid.vue';
import LocaleSwitcher from '../LocaleSwitcher.vue';
withDefaults(
@@ -18,7 +17,6 @@ withDefaults(
<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 }">
@@ -42,7 +40,7 @@ withDefaults(
.desktop-auth-page {
position: relative;
min-height: 100dvh;
background: linear-gradient(135deg, #001A33 0%, #001A33 50%, #002244 100%);
background: linear-gradient(135deg, #0f2027 0%, #203a43 50%, #0f2027 100%);
overflow: hidden;
}
@@ -50,7 +48,7 @@ withDefaults(
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(255, 255, 255, 0.06) 0%, transparent 70%);
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(244, 162, 97, 0.06) 0%, transparent 70%);
pointer-events: none;
}
@@ -59,7 +57,7 @@ withDefaults(
border-radius: 50%;
filter: blur(60px);
pointer-events: none;
opacity: 0.12;
opacity: 0.15;
z-index: 0;
}
@@ -68,7 +66,7 @@ withDefaults(
height: 320px;
top: 10%;
left: 12%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.5) 0%, transparent 70%);
background: radial-gradient(circle, rgba(244, 162, 97, 0.5) 0%, transparent 70%);
animation: ambient-drift-1 12s ease-in-out infinite alternate;
}
@@ -77,7 +75,7 @@ withDefaults(
height: 280px;
bottom: 12%;
right: 12%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.35) 0%, transparent 70%);
background: radial-gradient(circle, rgba(231, 111, 81, 0.35) 0%, transparent 70%);
animation: ambient-drift-2 16s ease-in-out infinite alternate;
}
@@ -111,41 +109,15 @@ withDefaults(
}
.auth-card {
position: relative;
width: 100%;
max-width: 720px;
background: linear-gradient(
165deg,
rgba(255, 255, 255, 0.06) 0%,
rgba(255, 255, 255, 0.03) 28%,
rgba(10, 37, 64, 0.55) 65%,
rgba(0, 20, 40, 0.75) 100%
);
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(22, 45, 54, 0.82);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 12px;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 8px 32px rgba(0, 0, 0, 0.4);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.38);
overflow: hidden;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.auth-card::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.28) 50%,
transparent 100%
);
pointer-events: none;
z-index: 1;
}
.auth-card--wide {
@@ -153,8 +125,6 @@ withDefaults(
}
.form-lang {
position: relative;
z-index: 1;
display: flex;
justify-content: flex-end;
padding: 14px 18px 0;
@@ -171,12 +141,8 @@ withDefaults(
align-items: center;
justify-content: center;
padding: 28px 20px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(0, 26, 51, 0.3) 100%
);
border-right: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.25);
border-right: 1px solid rgba(244, 162, 97, 0.15);
}
.logo-large {
@@ -184,14 +150,14 @@ withDefaults(
max-width: 220px;
height: auto;
object-fit: contain;
filter: drop-shadow(0 4px 24px rgba(255, 255, 255, 0.25));
filter: drop-shadow(0 4px 24px rgba(244, 162, 97, 0.25));
}
.form-fields {
display: flex;
flex-direction: column;
min-width: 0;
padding: 22px 28px 28px;
padding: 22px 24px 24px;
}
@media (max-width: 640px) {
@@ -212,7 +178,7 @@ withDefaults(
.form-brand {
padding: 20px 24px 12px;
border-right: none;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid rgba(244, 162, 97, 0.15);
}
.logo-large {

View File

@@ -255,7 +255,7 @@ onUnmounted(stopAutoPlay);
height: 100%;
border-radius: 14px;
overflow: hidden;
background: linear-gradient(180deg, #0A2540 0%, #001A33 100%);
background: linear-gradient(180deg, var(--secondary) 0%, var(--bg-body) 100%);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.45);
transition: box-shadow 0.35s ease, filter 0.35s ease, transform 0.25s ease;
display: flex;
@@ -276,8 +276,8 @@ onUnmounted(stopAutoPlay);
filter: none;
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 28px rgba(255, 255, 255, 0.22),
0 0 56px rgba(255, 255, 255, 0.1);
0 0 28px rgba(244, 162, 97, 0.22),
0 0 56px rgba(244, 162, 97, 0.1);
}
.coverflow-slide.is-active:hover .slide-card {
@@ -290,16 +290,16 @@ onUnmounted(stopAutoPlay);
transform: scale(1);
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 22px rgba(255, 255, 255, 0.16),
0 0 48px rgba(255, 255, 255, 0.08);
0 0 22px rgba(244, 162, 97, 0.16),
0 0 48px rgba(244, 162, 97, 0.08);
}
50% {
transform: scale(1.008);
box-shadow:
0 18px 44px rgba(0, 0, 0, 0.52),
0 0 32px rgba(255, 255, 255, 0.26),
0 0 60px rgba(255, 255, 255, 0.12);
0 0 32px rgba(244, 162, 97, 0.26),
0 0 60px rgba(244, 162, 97, 0.12);
}
}
@@ -318,13 +318,13 @@ onUnmounted(stopAutoPlay);
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.12), #0A2540);
background: linear-gradient(135deg, rgba(244, 162, 97, 0.12), var(--secondary));
}
.fallback-icon {
width: 40px;
height: 40px;
color: rgba(255, 255, 255, 0.35);
color: rgba(244, 162, 97, 0.35);
}
.click-hint {
@@ -338,7 +338,7 @@ onUnmounted(stopAutoPlay);
justify-content: center;
pointer-events: none;
z-index: 2;
background: rgba(0, 26, 51, 0.35);
background: rgba(0, 0, 0, 0.35);
border-radius: 50%;
}
@@ -346,7 +346,7 @@ onUnmounted(stopAutoPlay);
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.45);
border: 1px solid rgba(244, 162, 97, 0.45);
animation: click-ring-pulse 2.8s ease-out infinite;
}
@@ -354,7 +354,7 @@ onUnmounted(stopAutoPlay);
position: relative;
width: 15px;
height: 15px;
color: rgba(255, 255, 255, 0.88);
color: rgba(244, 162, 97, 0.88);
animation: click-icon-bob 2.8s ease-in-out infinite;
}
@@ -409,7 +409,7 @@ onUnmounted(stopAutoPlay);
height: 44px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 26, 51, 0.65);
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(8px);
color: #ffffff;
display: flex;

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import GoldSpinner from '../GoldSpinner.vue';
import DesktopDetailModalShell from './DesktopDetailModalShell.vue';
import { formatMoney } from '../../utils/localeDisplay';
const props = defineProps<{
visible: boolean;
betNo: string | null;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const { t, locale } = useI18n();
type BetDetail = {
betNo: string;
stake: string;
potentialReturn?: string;
status: string;
placedAt: string;
legs: Array<{
matchTitle?: string;
marketLabel?: string;
selectionName?: string;
odds?: number;
resultStatus?: string | null;
score?: { ht: string | null; ft: string | null } | null;
}>;
};
const detail = ref<BetDetail | null>(null);
const loading = ref(false);
const error = ref(false);
const SEL_TRANS: Record<string, Record<string, string>> = {
'主胜': { 'en-US': 'Home Win', 'ms-MY': 'Rumah Menang' },
'客胜': { 'en-US': 'Away Win', 'ms-MY': 'Tandang Menang' },
'和局': { 'en-US': 'Draw', 'ms-MY': 'Seri' },
'主': { 'en-US': 'Home', 'ms-MY': 'Rumah' },
'客': { 'en-US': 'Away', 'ms-MY': 'Tandang' },
'大': { 'en-US': 'Over', 'ms-MY': 'Atas' },
'小': { 'en-US': 'Under', 'ms-MY': 'Bawah' },
'单': { 'en-US': 'Odd', 'ms-MY': 'Ganjil' },
'双': { 'en-US': 'Even', 'ms-MY': 'Genap' },
'冠军': { 'en-US': 'Winner', 'ms-MY': 'Juara' },
};
function translateSel(name: string): string {
if (locale.value === 'zh-CN') return name;
const exact = SEL_TRANS[name];
if (exact) return exact[locale.value] ?? exact['en-US'] ?? name;
const sp = name.indexOf(' ');
if (sp > 0) {
const head = name.slice(0, sp);
const m = SEL_TRANS[head];
if (m) return (m[locale.value] ?? m['en-US'] ?? head) + name.slice(sp);
}
return name;
}
async function loadDetail(betNo: string) {
loading.value = true;
error.value = false;
detail.value = null;
try {
const { data } = await api.get(`/player/bets/${betNo}`);
detail.value = data.data ?? null;
} catch {
error.value = true;
} finally {
loading.value = false;
}
}
watch(
() => [props.visible, props.betNo] as const,
([vis, no]) => {
if (vis && no) void loadDetail(no);
if (!vis) {
detail.value = null;
error.value = false;
loading.value = false;
}
},
{ immediate: true },
);
function statusClass(status: string) {
const map: Record<string, string> = {
WON: 'status-won',
LOST: 'status-lost',
PENDING: 'status-pending',
PUSH: 'status-push',
};
return map[status?.toUpperCase()] ?? 'status-default';
}
function statusLabel(status: string) {
const map: Record<string, string> = {
WON: t('history.filter_won'),
LOST: t('history.filter_lost'),
PENDING: t('history.filter_pending'),
PUSH: t('history.filter_push'),
};
return map[status?.toUpperCase()] ?? status;
}
</script>
<template>
<DesktopDetailModalShell
:visible="visible"
:title="t('bet.detail_title')"
:max-width="680"
@update:visible="emit('update:visible', $event)"
>
<div v-if="loading" class="state">
<GoldSpinner :size="32" />
</div>
<div v-else-if="error" class="state">
<p>{{ t('common.load_failed') }}</p>
<button v-if="betNo" type="button" class="retry-btn" @click="loadDetail(betNo)">
{{ t('common.retry') }}
</button>
</div>
<template v-else-if="detail">
<div class="detail-card header-card">
<div class="bet-no">{{ detail.betNo }}</div>
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
<div class="amount-row">
<div class="amount-item">
<span class="amount-label">{{ t('bet.stake') }}</span>
<span class="amount-val">{{ formatMoney(detail.stake, locale) }}</span>
</div>
<div class="amount-item">
<span class="amount-label">{{ t('bet.potential_payout') }}</span>
<span class="amount-val gold">{{ detail.potentialReturn ? formatMoney(detail.potentialReturn, locale) : '-' }}</span>
</div>
<div class="amount-item">
<span class="amount-label">{{ t('bet.placed_at') }}</span>
<span class="amount-val date">{{ detail.placedAt ? new Date(detail.placedAt).toLocaleString() : '-' }}</span>
</div>
</div>
</div>
<div class="detail-card">
<div class="card-title">{{ t('bet.selections') }}</div>
<div v-for="(leg, idx) in detail.legs" :key="idx" class="sel-row">
<div class="sel-match">{{ leg.matchTitle || t('bet.match') }}</div>
<div class="sel-meta">
<span class="sel-market">{{ leg.marketLabel || '' }}</span>
<span class="sel-name">{{ translateSel(leg.selectionName || '') }}</span>
<span class="sel-odds">@{{ leg.odds }}</span>
<span v-if="leg.score?.ft" class="sel-score">({{ t('history.ft') }}: {{ leg.score.ft }})</span>
</div>
</div>
</div>
</template>
<div v-else class="state">{{ t('common.not_found') }}</div>
</DesktopDetailModalShell>
</template>
<style scoped>
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 48px 20px;
color: var(--text-muted);
font-size: 13px;
}
.retry-btn {
padding: 8px 20px;
border-radius: 6px;
border: 1px solid var(--primary);
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.detail-card {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #1a323c;
}
.detail-card:last-child {
border-bottom: none;
}
.header-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.bet-no {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.status-badge {
display: inline-block;
padding: 4px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
align-self: flex-start;
}
.status-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
.status-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
.status-pending { background: rgba(244, 162, 97, 0.12); color: var(--primary-light); }
.status-push { background: rgba(100, 100, 100, 0.12); color: #aaa; }
.status-default { background: rgba(100, 100, 100, 0.1); color: #888; }
.amount-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.amount-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.amount-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.amount-val {
font-size: 17px;
font-weight: 800;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.amount-val.gold { color: var(--primary-light); }
.amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.card-title {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.sel-row {
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.sel-row:last-child { border-bottom: none; }
.sel-match {
font-size: 14px;
font-weight: 700;
color: var(--text);
margin-bottom: 4px;
}
.sel-meta {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-muted);
}
.sel-name { color: var(--text); font-weight: 700; }
.sel-odds { color: var(--primary-light); font-weight: 800; margin-left: auto; }
</style>

View File

@@ -73,7 +73,7 @@ watch(
transform: translateY(-50%);
box-shadow:
0 2px 10px rgba(0, 0, 0, 0.5),
0 0 0 3px rgba(12, 40, 66, 0.92);
0 0 0 3px rgba(22, 45, 54, 0.92);
transition:
transform 0.22s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s,
@@ -95,10 +95,10 @@ watch(
.rail-toggle:hover {
border-color: var(--border-gold);
background: var(--primary);
color: #001A33;
color: #fff;
box-shadow:
0 4px 14px rgba(0, 0, 0, 0.6),
0 0 0 3px rgba(255, 255, 255, 0.15);
0 0 0 3px rgba(244, 162, 97, 0.15);
}
.rail-toggle:active {
@@ -153,8 +153,8 @@ watch(
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: var(--primary, rgba(255, 255, 255, 0.92));
color: #001A33;
background: var(--primary, #F4A261);
color: var(--bg-body);
font-size: 10px;
font-weight: 800;
line-height: 20px;

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import DesktopDetailModalShell from './DesktopDetailModalShell.vue';
import { formatMoney, formatMoneyCompact } from '../../utils/localeDisplay';
import type { CashbackRecord } from '../../utils/cashback';
const props = defineProps<{
visible: boolean;
record: CashbackRecord | null;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const { t, locale } = useI18n();
function formatPeriod(start: string, end: string) {
const opts: Intl.DateTimeFormatOptions = { month: '2-digit', day: '2-digit' };
const s = new Date(start).toLocaleDateString(locale.value, opts);
const e = new Date(end).toLocaleDateString(locale.value, opts);
return `${s} ${e}`;
}
function formatRate(rate: string) {
const n = parseFloat(rate);
if (!Number.isFinite(n)) return rate;
return `${(n * 100).toFixed(2)}%`;
}
function formatTime(value: string | null) {
if (!value) return '—';
return new Date(value).toLocaleString(locale.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
</script>
<template>
<DesktopDetailModalShell
:visible="visible"
:title="t('cashback.title')"
@update:visible="emit('update:visible', $event)"
>
<template v-if="record">
<div class="hero">
<div class="amount-hero">{{ formatMoney(record.amount, locale) }}</div>
<div class="batch-no">{{ record.batchNo }}</div>
</div>
<div class="detail-rows">
<div class="detail-row">
<span class="row-label">{{ t('cashback.period') }}</span>
<span class="row-val">{{ formatPeriod(record.periodStart, record.periodEnd) }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('cashback.effective_stake') }}</span>
<span class="row-val">{{ formatMoneyCompact(record.effectiveStake, locale) }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('cashback.rate') }}</span>
<span class="row-val">{{ formatRate(record.rate) }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.time') }}</span>
<span class="row-val muted">{{ formatTime(record.confirmedAt ?? record.createdAt) }}</span>
</div>
</div>
</template>
<div v-else class="state">{{ t('common.not_found') }}</div>
</DesktopDetailModalShell>
</template>
<style scoped>
.state {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--text-muted);
font-size: 13px;
}
.hero {
padding: 20px 20px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #162d36;
}
.amount-hero {
font-size: 30px;
font-weight: 800;
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Consolas', monospace;
color: var(--primary-light);
line-height: 1.1;
}
.batch-no {
margin-top: 6px;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
}
.detail-rows {
padding: 4px 0 12px;
background: #1a323c;
}
.detail-row {
display: flex;
gap: 16px;
padding: 11px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.detail-row:last-child {
border-bottom: none;
}
.row-label {
width: 108px;
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.row-val {
flex: 1;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.row-val.muted {
color: var(--text-muted);
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
const props = withDefaults(
defineProps<{
visible: boolean;
title: string;
maxWidth?: number;
}>(),
{ maxWidth: 560 },
);
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const { t } = useI18n();
function close() {
emit('update:visible', false);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.visible) close();
}
onMounted(() => document.addEventListener('keydown', onKeydown));
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
</script>
<template>
<Teleport to="body">
<Transition name="detail-modal-fade">
<div v-if="visible" class="detail-modal-overlay" @click.self="close">
<div
class="detail-modal"
role="dialog"
aria-modal="true"
:aria-label="title"
:style="{ maxWidth: `${maxWidth}px` }"
>
<header class="detail-modal-header">
<h2 class="detail-modal-title">{{ title }}</h2>
<button type="button" class="detail-modal-close" :aria-label="t('support.close')" @click="close">
</button>
</header>
<div class="detail-modal-body">
<slot />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.detail-modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: #0f2027;
background: rgba(15, 32, 39, 0.96);
}
.detail-modal {
width: 100%;
max-height: 84vh;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: #1a323c;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.65);
}
.detail-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #162d36;
flex-shrink: 0;
}
.detail-modal-title {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
}
.detail-modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.detail-modal-close:hover {
color: var(--text);
}
.detail-modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
background: #1a323c;
}
.detail-modal-fade-enter-active,
.detail-modal-fade-leave-active {
transition: opacity 0.18s ease;
}
.detail-modal-fade-enter-active .detail-modal,
.detail-modal-fade-leave-active .detail-modal {
transition: transform 0.18s ease;
}
.detail-modal-fade-enter-from,
.detail-modal-fade-leave-to {
opacity: 0;
}
.detail-modal-fade-enter-from .detail-modal,
.detail-modal-fade-leave-to .detail-modal {
transform: scale(0.97);
}
</style>

View File

@@ -10,7 +10,6 @@ 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, placement, close, cancelClose, scheduleClose } = useDesktopBetPopover();
@@ -26,7 +25,6 @@ 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) => {
@@ -138,7 +136,7 @@ async function executePlaceNow(item = confirmingItem.value || pendingItem.value)
await refreshProfile();
showPlaceConfirm.value = false;
close();
showSuccess.value = true;
showToast(t('bet.place_success'));
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
@@ -251,22 +249,20 @@ function addToParlayList() {
@confirm="confirmPlaceNow"
/>
<BetSuccessOverlay :show="showSuccess" @done="showSuccess = false" />
</template>
<style scoped>
.bet-popover {
position: fixed;
z-index: 1000;
width: 190px;
padding: 8px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(14, 44, 70, 0.9);
width: 172px;
padding: 6px 7px;
border-radius: 6px;
border: 1px solid var(--border-active);
background: var(--bg-card-elevated);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
box-shadow: var(--shadow-lg);
}
.bet-popover::after,
@@ -284,7 +280,7 @@ function addToParlayList() {
left: 50%;
transform: translateX(-50%);
border-width: 0 6px 6px 6px;
border-color: transparent transparent rgba(14, 44, 70, 0.9) transparent;
border-color: transparent transparent var(--bg-card-elevated) transparent;
z-index: 2;
}
@@ -293,7 +289,7 @@ function addToParlayList() {
left: 50%;
transform: translateX(-50%);
border-width: 0 7px 7px 7px;
border-color: transparent transparent rgba(255, 255, 255, 0.2) transparent;
border-color: transparent transparent var(--border-active) transparent;
z-index: 1;
}
@@ -302,7 +298,7 @@ function addToParlayList() {
left: 50%;
transform: translateX(-50%);
border-width: 6px 6px 0 6px;
border-color: rgba(14, 44, 70, 0.9) transparent transparent transparent;
border-color: var(--bg-card-elevated) transparent transparent transparent;
z-index: 2;
}
@@ -311,7 +307,7 @@ function addToParlayList() {
left: 50%;
transform: translateX(-50%);
border-width: 7px 7px 0 7px;
border-color: rgba(255, 255, 255, 0.2) transparent transparent transparent;
border-color: var(--border-active) transparent transparent transparent;
z-index: 1;
}
@@ -342,8 +338,8 @@ function addToParlayList() {
padding: 4px 6px;
margin-bottom: 6px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(244, 162, 97, 0.04);
border: 1px solid rgba(244, 162, 97, 0.15);
}
.pick {
@@ -375,13 +371,13 @@ function addToParlayList() {
margin-bottom: 5px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #001A33;
background: var(--bg-body);
color: #fff;
font-size: 10px;
}
.stake-input:focus {
border-color: rgba(255, 255, 255, 0.3);
border-color: rgba(244, 162, 97, 0.3);
outline: none;
}
@@ -426,8 +422,8 @@ function addToParlayList() {
padding: 5px;
border: none;
border-radius: 3px;
background: rgba(255, 255, 255, 0.92);
color: #3d2800;
background: var(--gradient-primary);
color: #ffffff;
font-size: 10px;
font-weight: 800;
cursor: pointer;
@@ -447,8 +443,8 @@ function addToParlayList() {
width: 100%;
padding: 4px;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(244, 162, 97, 0.25);
background: rgba(244, 162, 97, 0.03);
color: var(--primary-light);
font-size: 9px;
font-weight: 700;
@@ -457,6 +453,6 @@ function addToParlayList() {
}
.btn-outline:hover {
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
}
</style>

View File

@@ -107,19 +107,21 @@ 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, #0A2540 0%, #002244 100%);
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
box-shadow: var(--shadow);
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.outright-event-card:hover {
border-color: var(--border-gold);
box-shadow: var(--shadow-gold);
border-color: var(--border-active);
box-shadow: var(--shadow-lg);
}
.card-clickable-area {
@@ -167,7 +169,7 @@ const topSelections = computed(() => {
.settled-tag {
font-size: 10px;
font-weight: 800;
color: #c9a227;
color: var(--primary);
border: 1px solid rgba(201, 162, 39, 0.45);
border-radius: 999px;
padding: 1px 7px;
@@ -281,7 +283,7 @@ const topSelections = computed(() => {
.odds-btn:hover {
background: var(--border-gold-soft);
border-color: var(--border-gold);
box-shadow: 0 0 8px rgba(255, 255, 255, 0.25);
box-shadow: 0 0 8px rgba(244, 162, 97, 0.25);
}
.selection-name {

View File

@@ -0,0 +1,392 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import DesktopDetailModalShell from './DesktopDetailModalShell.vue';
import { formatMoney } from '../../utils/localeDisplay';
import type { DepositAuditLog, PlayerDepositOrder } from '../../utils/depositOrderLookup';
import {
auditActionTone,
auditActorSecondary,
formatDepositAuditRemark,
shouldShowAuditRejectInTimeline,
} from '../../utils/depositAuditDisplay';
const props = defineProps<{
visible: boolean;
order: PlayerDepositOrder | null;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const { t, locale } = useI18n();
const router = useRouter();
const detail = computed(() => props.order);
function close() {
emit('update:visible', false);
}
function statusClass(status: string) {
const map: Record<string, string> = {
APPROVED: 'status-won',
REJECTED: 'status-lost',
PENDING: 'status-pending',
};
return map[status?.toUpperCase()] ?? 'status-default';
}
function statusLabel(status: string) {
const s = status?.toUpperCase();
if (s === 'APPROVED') return t('recharge.status_approved');
if (s === 'REJECTED') return t('recharge.status_rejected');
return t('recharge.status_pending');
}
function methodLabel(order: PlayerDepositOrder) {
if (order.methodType === 'USDT') return 'USDT';
return order.paymentMethodName || order.methodType || '-';
}
function reapply(order: PlayerDepositOrder) {
close();
const query: Record<string, string> = {
orderId: order.id,
methodType: order.methodType,
amount: order.amount,
};
if (order.paymentMethodId) query.methodId = order.paymentMethodId;
void router.push({ path: '/wallet/recharge', query });
}
function normalizeText(value: string | null | undefined) {
return value?.trim() ?? '';
}
function orderNote(order: PlayerDepositOrder): { label: string; text: string } | null {
const rejectReason = normalizeText(order.rejectReason);
const remark = normalizeText(order.remark);
if (order.status === 'REJECTED') {
const text = rejectReason || remark;
if (!text) return null;
return { label: t('recharge.reject_reason'), text };
}
if (remark) return { label: t('recharge.remark'), text: remark };
return null;
}
function orderNoteLine(order: PlayerDepositOrder) {
const note = orderNote(order);
return note ? `${note.label}: ${note.text}` : null;
}
function auditActionLabel(action: string) {
const key = `recharge.audit_${action.toLowerCase()}`;
const translated = t(key);
return translated !== key ? translated : action;
}
function auditStepClass(action: string) {
return `audit-step--${auditActionTone(action)}`;
}
function formatAuditTime(iso: string) {
return new Date(iso).toLocaleString(locale.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function auditRemarkForTimeline(log: DepositAuditLog, order: PlayerDepositOrder) {
if (log.action === 'REJECTED' && !shouldShowAuditRejectInTimeline(log, order.rejectReason)) {
return null;
}
return formatDepositAuditRemark(log, t);
}
function auditLogsForDisplay(order: PlayerDepositOrder) {
return [...(order.auditLogs ?? [])]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((log) => ({
log,
actor: auditActorSecondary(log, t),
remark: auditRemarkForTimeline(log, order),
}));
}
function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
if (remark.text === t('wallet.remark_deposit_revoke_generic')) return remark.text;
return `${t('recharge.audit_remark_label')}: ${remark.text}`;
}
</script>
<template>
<DesktopDetailModalShell
:visible="visible"
:title="t('recharge.order_detail')"
:max-width="680"
@update:visible="emit('update:visible', $event)"
>
<template v-if="detail">
<div class="detail-card header-card">
<div class="order-no">{{ detail.orderNo }}</div>
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
<div class="amount-row">
<div class="amount-item">
<span class="amount-label">{{ t('wallet.amount') }}</span>
<span class="amount-val">{{ formatMoney(detail.amount, locale) }}</span>
</div>
<div
v-if="detail.approvedAmount && detail.approvedAmount !== detail.amount"
class="amount-item"
>
<span class="amount-label">{{ t('recharge.credited') }}</span>
<span class="amount-val gold">{{ formatMoney(detail.approvedAmount, locale) }}</span>
</div>
<div class="amount-item">
<span class="amount-label">{{ t('recharge.method') }}</span>
<span class="amount-val method">{{ methodLabel(detail) }}</span>
</div>
<div class="amount-item">
<span class="amount-label">{{ t('recharge.apply_time') }}</span>
<span class="amount-val date">{{ new Date(detail.createdAt).toLocaleString() }}</span>
</div>
<div v-if="detail.reviewedAt" class="amount-item">
<span class="amount-label">{{ t('recharge.review_time') }}</span>
<span class="amount-val date">{{ new Date(detail.reviewedAt).toLocaleString() }}</span>
</div>
</div>
<div
v-if="orderNoteLine(detail)"
:class="detail.status === 'REJECTED' ? 'reject-reason' : 'order-remark'"
>
{{ orderNoteLine(detail) }}
</div>
<button
v-if="detail.status === 'REJECTED'"
type="button"
class="reapply-btn"
@click="reapply(detail)"
>
{{ t('recharge.reapply') }}
</button>
</div>
<div v-if="detail.auditLogs?.length" class="detail-card">
<div class="card-title">{{ t('recharge.audit_title') }}</div>
<div class="audit-track">
<div
v-for="(entry, logIdx) in auditLogsForDisplay(detail)"
:key="entry.log.id"
class="audit-step"
:class="auditStepClass(entry.log.action)"
>
<div class="audit-step-rail" aria-hidden="true">
<span class="audit-dot" />
<span v-if="logIdx < auditLogsForDisplay(detail).length - 1" class="audit-line" />
</div>
<div class="audit-step-body">
<div class="audit-step-head">
<span class="audit-step-title">{{ auditActionLabel(entry.log.action) }}</span>
<time class="audit-step-time">{{ formatAuditTime(entry.log.createdAt) }}</time>
</div>
<p v-if="entry.actor" class="audit-step-actor">{{ entry.actor }}</p>
<p
v-if="entry.log.approvedAmount && entry.log.action === 'APPROVED'"
class="audit-step-credited"
>
{{ t('recharge.audit_credited') }} {{ formatMoney(entry.log.approvedAmount, locale) }}
</p>
<div v-if="entry.remark?.kind === 'reject'" class="audit-step-box audit-step-box--reject">
<span class="audit-step-box-label">{{ t('recharge.reject_reason') }}</span>
<span class="audit-step-box-text">{{ entry.remark.text }}</span>
</div>
<p v-else-if="entry.remark?.kind === 'note'" class="audit-step-note">
{{ auditNoteDisplayText(entry.remark) }}
</p>
</div>
</div>
</div>
</div>
</template>
<div v-else class="state">{{ t('common.not_found') }}</div>
</DesktopDetailModalShell>
</template>
<style scoped>
.state {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--text-muted);
font-size: 13px;
}
.detail-card {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #1a323c;
}
.detail-card:last-child {
border-bottom: none;
}
.header-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.order-no {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.04em;
}
.status-badge {
display: inline-block;
padding: 4px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
align-self: flex-start;
}
.status-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
.status-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
.status-pending { background: rgba(244, 162, 97, 0.12); color: var(--primary-light); }
.status-default { background: rgba(100, 100, 100, 0.1); color: #888; }
.amount-row {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.amount-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.amount-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.amount-val {
font-size: 17px;
font-weight: 800;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.amount-val.gold { color: var(--primary-light); }
.amount-val.method { font-size: 14px; font-weight: 700; }
.amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.reject-reason {
padding: 10px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 69, 58, 0.22);
font-size: 13px;
color: #ff8a82;
line-height: 1.45;
word-break: break-word;
}
.order-remark {
font-size: 13px;
color: var(--text-muted);
line-height: 1.45;
}
.reapply-btn {
align-self: flex-start;
padding: 9px 24px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-gold-soft);
background: rgba(244, 162, 97, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.card-title {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.audit-track { display: flex; flex-direction: column; }
.audit-step { display: flex; gap: 10px; }
.audit-step-rail {
flex: 0 0 12px;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5px;
}
.audit-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.audit-line {
flex: 1;
width: 1px;
min-height: 12px;
margin: 4px 0;
background: rgba(255, 255, 255, 0.08);
}
.audit-step-body { flex: 1; min-width: 0; padding-bottom: 14px; }
.audit-step:last-child .audit-step-body { padding-bottom: 0; }
.audit-step-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.audit-step-title { font-size: 13px; font-weight: 700; color: var(--text); }
.audit-step-time { font-size: 11px; color: var(--text-muted); white-space: nowrap; }
.audit-step-actor { margin: 3px 0 0; font-size: 12px; color: var(--text-muted); }
.audit-step-credited { margin: 4px 0 0; font-size: 12px; font-weight: 600; color: #7eb87a; }
.audit-step-note { margin: 4px 0 0; font-size: 12px; color: var(--text-muted); line-height: 1.45; }
.audit-step-box {
margin-top: 4px;
padding: 8px 10px;
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 2px;
}
.audit-step-box--reject { border: 1px solid rgba(255, 69, 58, 0.22); }
.audit-step-box-label { font-size: 10px; font-weight: 600; color: #c07070; }
.audit-step-box-text { font-size: 12px; color: #b08888; }
.audit-step--submitted .audit-dot { background: var(--primary); }
.audit-step--approved .audit-dot { background: #4cd964; }
.audit-step--rejected .audit-dot { background: #ff453a; }
.audit-step--revoked .audit-dot { background: #888; }
.audit-step--reopened .audit-dot { background: var(--primary); }
</style>

View File

@@ -74,7 +74,7 @@ const activeTab = computed(() => {
<div class="nav-right">
<LocaleSwitcher />
<template v-if="auth.user">
<CashBalanceChip hide-avatar />
<CashBalanceChip />
<UserAvatarMenu />
</template>
<div v-else class="auth-buttons">
@@ -114,7 +114,7 @@ const activeTab = computed(() => {
height: 38px;
width: auto;
display: block;
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.2));
filter: drop-shadow(0 0 4px rgba(244, 162, 97, 0.2));
}
.site-name {
@@ -122,10 +122,10 @@ const activeTab = computed(() => {
font-weight: 800;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
letter-spacing: 0.5px;
background: var(--gradient-gold);
background: var(--gradient-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 8px rgba(255, 255, 255, 0.15);
text-shadow: 0 0 8px rgba(244, 162, 97, 0.25);
transition: opacity 0.2s;
}
@@ -169,7 +169,7 @@ const activeTab = computed(() => {
height: 3px;
background: var(--primary);
border-radius: 3px 3px 0 0;
box-shadow: 0 -1px 8px rgba(255, 255, 255, 0.5);
box-shadow: 0 -1px 8px rgba(244, 162, 97, 0.5);
}
.badge {
@@ -204,7 +204,7 @@ const activeTab = computed(() => {
padding: 6px 18px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: rgba(255, 255, 255, 0.05);
background: rgba(244, 162, 97, 0.05);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
@@ -212,15 +212,15 @@ const activeTab = computed(() => {
}
.login-btn:hover {
background: rgba(255, 255, 255, 0.12);
background: rgba(244, 162, 97, 0.12);
border-color: var(--primary);
}
.register-btn {
padding: 7px 18px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.92);
color: #3D2800;
background: var(--gradient-primary);
color: #fff;
font-size: 13px;
font-weight: 800;
transition: opacity 0.2s;

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../GoldSpinner.vue';
import DesktopDetailModalShell from './DesktopDetailModalShell.vue';
import { formatMoney } from '../../utils/localeDisplay';
import { txTypeKey, txDisplayType, txSummaryLabel, isCashbackType } from '../../utils/walletTx';
const props = defineProps<{
visible: boolean;
transactionId: string | null;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const { t, locale } = useI18n();
const router = useRouter();
type TxDetail = {
transactionId: string;
transactionType: string;
displayType?: string;
summary?: string | null;
amount: string;
balanceBefore?: string;
balanceAfter?: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
referenceType?: string | null;
referenceId?: string | null;
betNo?: string | null;
cashbackBatchNo?: string | null;
};
const detail = ref<TxDetail | null>(null);
const loading = ref(false);
const error = ref(false);
async function loadDetail(id: string) {
loading.value = true;
error.value = false;
detail.value = null;
try {
const { data } = await api.get(`/player/wallet/transactions/${id}`);
detail.value = data.data ?? null;
} catch {
error.value = true;
} finally {
loading.value = false;
}
}
watch(
() => [props.visible, props.transactionId] as const,
([vis, id]) => {
if (vis && id) void loadDetail(id);
if (!vis) {
detail.value = null;
error.value = false;
loading.value = false;
}
},
{ immediate: true },
);
function close() {
emit('update:visible', false);
}
function txLabel(tx: TxDetail): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return tx.transactionType;
}
const showFrozen = (tx: TxDetail) =>
parseFloat(tx.frozenBefore || '0') > 0 || parseFloat(tx.frozenAfter || '0') > 0;
const betNo = computed(() => detail.value?.betNo?.trim() || null);
const cashbackBatchNo = computed(() => {
const tx = detail.value;
if (!tx || !isCashbackType(tx.transactionType)) return null;
return tx.cashbackBatchNo ?? tx.referenceId ?? null;
});
function goBetDetail() {
if (!betNo.value) return;
close();
void router.push({ path: '/bets', query: { betNo: betNo.value } });
}
function goCashbackDetail() {
if (!cashbackBatchNo.value) return;
close();
void router.push({ path: '/wallet/cashbacks', query: { batchNo: cashbackBatchNo.value } });
}
</script>
<template>
<DesktopDetailModalShell
:visible="visible"
:title="t('wallet.transaction_detail')"
@update:visible="emit('update:visible', $event)"
>
<div v-if="loading" class="state">
<GoldSpinner :size="32" />
</div>
<div v-else-if="error" class="state">
<p>{{ t('common.load_failed') }}</p>
<button v-if="transactionId" type="button" class="retry-btn" @click="loadDetail(transactionId)">
{{ t('common.retry') }}
</button>
</div>
<template v-else-if="detail">
<div class="hero">
<div class="amount-hero" :class="parseFloat(detail.amount) >= 0 ? 'pos' : 'neg'">
{{ formatMoney(detail.amount, locale) }}
</div>
<div class="type-label">{{ txLabel(detail) }}</div>
</div>
<div class="detail-rows">
<div class="detail-row">
<span class="row-label">{{ t('wallet.note') }}</span>
<span class="row-val">{{ txSummaryLabel(detail, t) || '-' }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.balance_before') }}</span>
<span class="row-val mono">{{ formatMoney(detail.balanceBefore, locale) }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.balance_after') }}</span>
<span class="row-val mono">{{ formatMoney(detail.balanceAfter, locale) }}</span>
</div>
<div v-if="showFrozen(detail)" class="detail-row">
<span class="row-label">{{ t('wallet.frozen_before') }}</span>
<span class="row-val mono">{{ formatMoney(detail.frozenBefore, locale) }}</span>
</div>
<div v-if="showFrozen(detail)" class="detail-row">
<span class="row-label">{{ t('wallet.frozen_after') }}</span>
<span class="row-val mono">{{ formatMoney(detail.frozenAfter, locale) }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.time') }}</span>
<span class="row-val muted">{{ new Date(detail.createdAt).toLocaleString() }}</span>
</div>
<div class="detail-row">
<span class="row-label">{{ t('wallet.transaction_id') }}</span>
<span class="row-val mono muted">{{ detail.transactionId }}</span>
</div>
</div>
<div v-if="betNo || cashbackBatchNo" class="link-actions">
<button v-if="betNo" type="button" class="link-btn" @click="goBetDetail">
{{ t('wallet.detail_bet_link') }} · {{ betNo }}
</button>
<button v-if="cashbackBatchNo" type="button" class="link-btn" @click="goCashbackDetail">
{{ t('wallet.detail_cashback_link') }} · {{ cashbackBatchNo }}
</button>
</div>
</template>
<div v-else class="state">{{ t('common.not_found') }}</div>
</DesktopDetailModalShell>
</template>
<style scoped>
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 48px 20px;
color: var(--text-muted);
font-size: 13px;
}
.retry-btn {
padding: 8px 20px;
border-radius: 6px;
border: 1px solid var(--primary);
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.hero {
padding: 20px 20px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #162d36;
}
.amount-hero {
font-size: 32px;
font-weight: 800;
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Consolas', monospace;
line-height: 1.1;
}
.amount-hero.pos { color: var(--primary-light); }
.amount-hero.neg { color: var(--danger); }
.type-label {
margin-top: 6px;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
}
.detail-rows {
padding: 4px 0;
background: #1a323c;
}
.detail-row {
display: flex;
gap: 16px;
padding: 11px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.detail-row:last-child {
border-bottom: none;
}
.row-label {
width: 108px;
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.row-val {
flex: 1;
font-size: 13px;
font-weight: 700;
color: var(--text);
word-break: break-all;
}
.row-val.muted {
color: var(--text-muted);
font-weight: 600;
}
.mono {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12px;
}
.link-actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 20px 18px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: #1a323c;
}
.link-btn {
width: 100%;
padding: 9px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-gold-soft);
background: rgba(244, 162, 97, 0.08);
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
text-align: left;
}
.link-btn:hover {
background: rgba(244, 162, 97, 0.14);
}
</style>

View File

@@ -197,7 +197,7 @@ const handleLeagueClick = (leagueId: string) => {
.sidebar-search-input {
width: 100%;
background: #002244 !important;
background: var(--bg-body) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
@@ -207,7 +207,7 @@ const handleLeagueClick = (leagueId: string) => {
.sidebar-search-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1) !important;
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.1) !important;
}
.sidebar-section {
@@ -231,7 +231,7 @@ const handleLeagueClick = (leagueId: string) => {
.time-btn {
flex: 1;
background: #0A2540;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 5px 2px;
@@ -248,7 +248,7 @@ const handleLeagueClick = (leagueId: string) => {
}
.time-btn.active {
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
border-color: var(--primary);
color: var(--primary-light);
}
@@ -300,7 +300,7 @@ const handleLeagueClick = (leagueId: string) => {
}
.league-item.active {
background: rgba(255, 255, 255, 0.04);
background: rgba(244, 162, 97, 0.04);
}
.checkbox {
@@ -310,7 +310,7 @@ const handleLeagueClick = (leagueId: string) => {
border-radius: 2px;
position: relative;
flex-shrink: 0;
background: #002244;
background: var(--bg-body);
}
.checkbox.checked {
@@ -325,7 +325,7 @@ const handleLeagueClick = (leagueId: string) => {
left: 4px;
width: 3px;
height: 6px;
border: solid #001A33;
border: solid var(--bg-body);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
@@ -349,7 +349,7 @@ const handleLeagueClick = (leagueId: string) => {
font-size: 9px;
color: var(--text-muted);
font-weight: 700;
background: #181818;
background: rgba(22, 45, 54, 0.4); border: 1px solid var(--border);
padding: 1px 5px;
border-radius: 8px;
}

View File

@@ -161,7 +161,7 @@ watch(currentMatchId, scrollToActive);
.sidebar-search-input {
width: 100%;
background: #002244 !important;
background: var(--bg-body) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
@@ -171,7 +171,7 @@ watch(currentMatchId, scrollToActive);
.sidebar-search-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1) !important;
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.1) !important;
}
.sidebar-section {
@@ -201,7 +201,7 @@ watch(currentMatchId, scrollToActive);
.time-btn {
flex: 1;
background: #0A2540;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 5px 2px;
@@ -218,7 +218,7 @@ watch(currentMatchId, scrollToActive);
}
.time-btn.active {
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
border-color: var(--primary);
color: var(--primary-light);
}
@@ -324,7 +324,7 @@ watch(currentMatchId, scrollToActive);
.matches-list :deep(.match-card) {
padding: 10px 8px;
min-height: unset;
background: #0A2540;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
gap: 6px;
@@ -337,7 +337,7 @@ watch(currentMatchId, scrollToActive);
.matches-list :deep(.match-card.active) {
border-color: var(--primary) !important;
background: rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.25);
background: rgba(244, 162, 97, 0.08) !important;
box-shadow: 0 0 8px rgba(244, 162, 97, 0.25);
}
</style>

View File

@@ -126,7 +126,7 @@ watch(currentOutrightId, scrollToActive);
.sidebar-search-input {
width: 100%;
background: #002244 !important;
background: var(--bg-body) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
@@ -136,7 +136,7 @@ watch(currentOutrightId, scrollToActive);
.sidebar-search-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1) !important;
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.1) !important;
}
.sidebar-section {
@@ -188,7 +188,7 @@ watch(currentOutrightId, scrollToActive);
.outright-card {
padding: 12px 10px;
background: #0A2540;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
@@ -205,8 +205,8 @@ watch(currentOutrightId, scrollToActive);
.outright-card.active {
border-color: var(--primary) !important;
background: rgba(255, 255, 255, 0.08) !important;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.25);
background: rgba(244, 162, 97, 0.08) !important;
box-shadow: 0 0 8px rgba(244, 162, 97, 0.25);
}
.card-header {
@@ -251,15 +251,15 @@ watch(currentOutrightId, scrollToActive);
font-size: 9px;
color: var(--text-muted);
font-weight: 700;
background: #0A2540;
background: rgba(22, 45, 54, 0.45); border: 1px solid var(--border);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.03);
}
.outright-card.active .selections-badge {
background: rgba(255, 255, 255, 0.15);
background: rgba(244, 162, 97, 0.15);
color: var(--primary-light);
border-color: rgba(255, 255, 255, 0.3);
border-color: rgba(244, 162, 97, 0.3);
}
</style>

View File

@@ -120,7 +120,7 @@ function onPageSizeChange(e: Event) {
align-items: center;
border-radius: 6px;
border: 1px solid var(--border);
background: #0A2540;
background: var(--bg-elevated);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
@@ -131,13 +131,13 @@ function onPageSizeChange(e: Event) {
.page-btn:hover:not(:disabled) {
border-color: var(--border-gold-soft);
color: var(--text);
background: rgba(255, 255, 255, 0.05);
background: rgba(244, 162, 97, 0.05);
}
.page-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #001A33;
color: var(--bg-body);
font-weight: 800;
}
@@ -154,7 +154,7 @@ function onPageSizeChange(e: Event) {
.page-size-select {
height: 32px;
background: #0A2540;
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 6px;

View File

@@ -78,9 +78,9 @@ function selectSport(id: SportCategory, enabled: boolean) {
.sport-category.active {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.1);
background: rgba(244, 162, 97, 0.1);
border-color: var(--border-gold-soft);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(244, 162, 97, 0.08);
}
.sport-category.disabled {

View File

@@ -96,18 +96,18 @@ function formatOdds(odds: string) {
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
background: rgba(0, 0, 0, 0.5);
z-index: 210;
display: flex;
align-items: flex-end;
}
.drawer {
background: var(--gradient-card);
background: var(--bg-card);
width: 100%;
max-height: 80vh;
border-radius: 16px 16px 0 0;
border-top: 1px solid var(--border-gold-soft);
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -118,15 +118,14 @@ function formatOdds(odds: string) {
overflow-y: auto;
padding: 10px 14px 0;
-webkit-overflow-scrolling: touch;
background: var(--bg-card);
}
.drawer-foot {
flex-shrink: 0;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: var(--bg-card);
border-top: 1px solid var(--border-gold-soft);
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
background: var(--bg-elevated);
border-top: 1px solid var(--border);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.2);
}
.drawer-header {
@@ -135,7 +134,7 @@ function formatOdds(odds: string) {
align-items: center;
padding: 14px 14px 12px;
flex-shrink: 0;
background: var(--bg-elevated);
background: var(--bg-card);
border-bottom: 1px solid var(--border);
z-index: 2;
}
@@ -143,10 +142,11 @@ function formatOdds(odds: string) {
.drawer-header h3 {
font-size: 16px;
font-weight: 800;
color: var(--text);
}
.count {
color: var(--primary-light);
color: var(--primary);
}
.close-btn {
@@ -158,15 +158,16 @@ function formatOdds(odds: string) {
.slip-item {
padding: 10px 12px;
background: var(--bg-body);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: 8px;
margin-bottom: 8px;
}
.item-name {
font-size: 13px;
font-weight: 800;
color: var(--text);
}
.item-sel {
@@ -176,7 +177,7 @@ function formatOdds(odds: string) {
}
.odds {
color: var(--primary-light);
color: var(--warning);
font-weight: 800;
}
@@ -195,7 +196,7 @@ function formatOdds(odds: string) {
}
.summary-row strong {
color: var(--primary-light);
color: var(--primary);
font-size: 15px;
font-weight: 800;
}
@@ -206,7 +207,7 @@ function formatOdds(odds: string) {
}
.return strong {
color: var(--primary-light);
color: var(--primary);
font-size: 18px;
font-weight: 800;
}

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import { groupCorrectScoreSelections, type CsSelection } from '../../utils/correctScoreLayout';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
const props = defineProps<{
marketType: string;
selections: Array<{
@@ -86,6 +85,8 @@ function formatOdds(odds: string) {
<style scoped>
.cs-panel {
padding: 6px 8px 0;
background: var(--bg-card-elevated);
border-top: 1px solid var(--border);
}
.cs-panel--locked {
@@ -121,6 +122,7 @@ function formatOdds(odds: string) {
align-self: start;
}
/* ── Score Card ── */
.score-card {
display: flex;
flex-direction: column;
@@ -129,34 +131,42 @@ function formatOdds(odds: string) {
gap: 3px;
min-height: 42px;
padding: 6px 3px;
background: #161616;
border: 1px solid #282828;
border-radius: 3px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.score-card:hover:not(.score-card--locked) {
border-color: var(--primary-light);
background: var(--bg-hover);
}
.score-card.selected {
border-color: var(--primary);
background: #2d281a;
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.35), 0 0 0 1px rgba(212, 175, 55, 0.2);
border-width: 2px;
background: rgba(244, 162, 97, 0.08);
}
.score-card.selected .score-line {
color: #e8d48a;
color: var(--primary-light);
}
.score-card.selected .odds {
color: #ffffff;
color: var(--primary);
}
.score-card--locked {
background: #111;
border-color: #333;
background: var(--bg-elevated);
border-color: var(--border);
cursor: not-allowed;
}
.score-card--locked .score-line,
.score-card--locked .odds {
color: #666;
color: var(--text-muted);
}
.score-line {
@@ -168,9 +178,10 @@ function formatOdds(odds: string) {
.odds {
font-size: 10px;
font-weight: 700;
color: #ffffff;
color: var(--warning);
}
/* ── Dense 紧凑模式 ── */
.cs-panel--dense {
padding: 4px 6px 6px;
}
@@ -192,6 +203,7 @@ function formatOdds(odds: string) {
min-height: 34px;
padding: 4px 2px;
gap: 2px;
border-radius: 4px;
}
.cs-panel--dense .score-line {

View File

@@ -4,7 +4,6 @@ import { useI18n } from 'vue-i18n';
import { resolveSelectionLabel } from '../../utils/selectionLabel';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
const props = defineProps<{
selections: {
id: string;
@@ -15,7 +14,7 @@ const props = defineProps<{
}[];
isSelected: (id: string) => boolean;
compact?: boolean;
/** PC 行内横排:左标签右赔率,适合详情页全宽 */
/** PC 行内横排:左标签右赔率,适合详情页全宽 */
horizontal?: boolean;
/** PC 卡片内:上标签下赔率,列数随选项数量 */
desktopCard?: boolean;
@@ -80,10 +79,12 @@ const panelStyle = computed(() =>
</div>
</template>
<style scoped>
.wrap {
padding: 6px 8px 8px;
background: var(--bg-input);
background: var(--bg-card-elevated);
border-top: 1px solid var(--border);
}
.wrap.compact {
@@ -99,6 +100,22 @@ const panelStyle = computed(() =>
background: transparent;
}
.wrap.desktopCard {
padding: 0;
background: transparent;
}
.wrap.locked {
opacity: 0.78;
}
/* ── 基础网格 ── */
.panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.wrap.compact .panel:not(.horizontal) {
gap: 2px;
}
@@ -109,6 +126,7 @@ const panelStyle = computed(() =>
font-size: 10px;
}
/* ── Horizontal 行内布局 ── */
.panel.horizontal {
display: flex;
flex-wrap: wrap;
@@ -138,11 +156,7 @@ const panelStyle = computed(() =>
flex-shrink: 0;
}
.wrap.desktopCard {
padding: 0;
background: transparent;
}
/* ── Desktop Card 紧凑布局 ── */
.panel.desktop-card {
display: grid;
grid-template-columns: repeat(var(--market-cols, 3), minmax(0, 1fr));
@@ -151,14 +165,14 @@ const panelStyle = computed(() =>
}
.panel.desktop-card .odds-btn {
min-height: 38px;
padding: 4px 6px;
gap: 2px;
min-height: 34px;
padding: 3px 4px;
gap: 1px;
}
.panel.desktop-card .odds-btn .label {
font-size: 9px;
line-height: 1.2;
font-size: 8px;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
@@ -166,20 +180,11 @@ const panelStyle = computed(() =>
}
.panel.desktop-card .odds-btn .odds {
font-size: 13px;
font-size: 12px;
line-height: 1.1;
}
.wrap.locked {
opacity: 0.78;
}
.panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
/* ── Odds Button ── */
.odds-btn {
display: flex;
flex-direction: column;
@@ -188,52 +193,54 @@ const panelStyle = computed(() =>
gap: 2px;
min-height: 44px;
padding: 6px 4px;
border-radius: 4px;
background: var(--gradient-card-soft);
border-radius: 8px;
background: var(--bg-card);
border: 1px solid var(--border);
text-align: center;
transition: border-color 0.15s, background 0.15s;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.odds-btn:hover:not(.odds-btn--locked) {
border-color: var(--border-strong);
border-color: var(--primary-light);
background: var(--bg-hover);
}
.odds-btn.selected {
border-color: var(--border-strong);
background: var(--surface-active);
box-shadow: inset 0 0 0 1px var(--border-gold);
border-color: var(--primary);
border-width: 2px;
background: rgba(244, 162, 97, 0.08);
}
.odds-btn.selected .label {
color: var(--text);
color: var(--primary-light);
}
.odds-btn.selected .odds {
color: var(--text);
color: var(--primary);
}
.odds-btn--locked {
background: var(--bg-body);
background: var(--bg-elevated);
border-color: var(--border);
cursor: not-allowed;
}
.odds-btn--locked .label,
.odds-btn--locked .odds {
color: var(--text-dim);
color: var(--text-muted);
}
.label {
font-size: 9px;
color: var(--text-muted);
font-size: 10px;
color: var(--text-secondary);
line-height: 1.15;
font-weight: 600;
}
.odds {
font-size: 14px;
font-size: 15px;
font-weight: 800;
color: var(--text);
color: var(--warning);
}
</style>

View File

@@ -1,17 +1,31 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
defineProps<{
label: string;
promoLabel?: string;
statusLabel?: string;
expanded: boolean;
hasMarket: boolean;
}>();
const emit = defineEmits<{ toggle: [] }>();
const { t } = useI18n();
</script>
<template>
<div class="row">
<button
type="button"
class="row"
:class="{ expanded, disabled: !hasMarket }"
:disabled="!hasMarket"
@click="emit('toggle')"
>
<span class="row-label">{{ label }}</span>
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
<span v-if="statusLabel" class="row-status">{{ statusLabel }}</span>
</div>
<span v-else-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
</button>
</template>
<style scoped>
@@ -21,22 +35,36 @@ defineProps<{
gap: 8px;
width: 100%;
padding: 13px 12px;
background: rgba(255, 255, 255, 0.03);
background: transparent;
border: none;
text-align: left;
transition: background 0.15s ease;
border-radius: 0;
position: relative;
overflow: hidden;
}
.row::before {
.row:not(.disabled):active {
background: rgba(244, 162, 97, 0.07);
}
.row.expanded {
background: rgba(244, 162, 97, 0.07);
}
.row.expanded::before {
content: '';
position: absolute;
top: 0;
left: 12px;
right: 12px;
height: 2px;
background: linear-gradient(90deg, transparent 0%, var(--primary) 30%, var(--primary-light) 50%, var(--primary) 70%, transparent 100%);
opacity: 0.6;
height: 2.5px;
background: var(--primary);
opacity: 1;
}
.row.disabled {
opacity: 0.4;
}
.row-label {
@@ -44,7 +72,7 @@ defineProps<{
min-width: 0;
font-size: 13px;
font-weight: 700;
color: var(--primary-light);
color: var(--text);
letter-spacing: 0.02em;
}
@@ -52,11 +80,11 @@ defineProps<{
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
color: var(--primary-light);
color: var(--warning);
padding: 2px 6px;
border-radius: 3px;
background: var(--surface-accent);
border: 1px solid var(--border-gold-soft);
background: rgba(255, 179, 0, 0.1);
border: 1px solid rgba(255, 179, 0, 0.3);
}
.row-status {
@@ -69,4 +97,14 @@ defineProps<{
background: rgba(240, 180, 41, 0.12);
border: 1px solid rgba(240, 180, 41, 0.35);
}
.row.expanded .row-label {
color: var(--primary);
}
.row-muted {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
}
</style>

View File

@@ -258,16 +258,24 @@ function formatOdds(odds: string) {
</script>
<template>
<Transition name="modal-fade">
<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">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M1 1L13 13M13 1L1 13" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
</button>
<template v-if="step === 'form'">
<p class="hint">{{ t('bet.outright_enter_stake') }}</p>
<div class="modal-header">
<p class="hint">{{ t('bet.outright_enter_stake') }}</p>
</div>
<div class="hero">
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
<p class="team">{{ pick.teamName }}</p>
<div class="hero-info">
<p class="team">{{ pick.teamName }}</p>
<p class="event-title">{{ pick.eventTitle }}</p>
</div>
<span
class="odds-badge"
:class="{
@@ -277,16 +285,16 @@ function formatOdds(odds: string) {
}"
>
<template v-if="oddsDelta && !oddsDelta.suspended">
@ {{ oddsDelta.oldOdds.toFixed(2) }} {{ oddsDelta.newOdds.toFixed(2) }}
<span class="odds-old">{{ oddsDelta.oldOdds.toFixed(2) }}</span>
<span class="odds-arrow"></span>
<span class="odds-new">{{ oddsDelta.newOdds.toFixed(2) }}</span>
</template>
<template v-else>
@ {{ formatOdds(currentOdds || pick.odds) }}
{{ formatOdds(currentOdds || pick.odds) }}
</template>
</span>
</div>
<p class="event-title">{{ pick.eventTitle }}</p>
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<div class="balance-row">
@@ -294,27 +302,33 @@ function formatOdds(odds: string) {
<span class="balance-value">{{ balanceText }}</span>
</div>
<label class="stake-label">{{ t('bet.stake_label') }}</label>
<input
v-model.number="stake"
type="number"
class="stake-input"
min="0.01"
step="0.01"
inputmode="decimal"
:placeholder="t('bet.stake_placeholder')"
/>
<div class="stake-section">
<label class="stake-label">{{ t('bet.stake_label') }}</label>
<div class="stake-input-wrap">
<input
v-model.number="stake"
type="number"
class="stake-input"
min="0.01"
step="0.01"
inputmode="decimal"
:placeholder="t('bet.stake_placeholder')"
/>
</div>
<div class="quick-stakes">
<button type="button" class="chip" @click="setStake(50)">50</button>
<button type="button" class="chip" @click="setStake(100)">100</button>
<button type="button" class="chip" @click="setStake(500)">500</button>
<button type="button" class="chip" @click="setMaxStake">{{ t('bet.stake_max') }}</button>
<div class="quick-stakes">
<button type="button" class="chip" @click="setStake(50)">50</button>
<button type="button" class="chip" @click="setStake(100)">100</button>
<button type="button" class="chip" @click="setStake(500)">500</button>
<button type="button" class="chip" @click="setStake(1000)">1000</button>
<button type="button" class="chip chip-max" @click="setMaxStake">{{ t('bet.stake_max') }}</button>
</div>
</div>
<p class="est-return">
{{ t('history.est_return') }}<span class="est-value">{{ estReturnText }}</span>
</p>
<div class="est-return">
<span class="est-label">{{ t('history.est_return') }}</span>
<span class="est-value">{{ estReturnText }}</span>
</div>
<p v-if="error" class="error">{{ error }}</p>
@@ -324,73 +338,97 @@ function formatOdds(odds: string) {
</button>
<button
type="button"
class="btn-confirm btn-gold-outline"
class="btn-confirm"
:disabled="loading || stake <= 0 || hasSuspendedSelection"
@click="submit"
>
<span v-if="loading" class="btn-spinner"></span>
{{ submitButtonLabel }}
</button>
</div>
</template>
<template v-else>
<div class="success-icon"></div>
<div class="success-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M5 12.5L10 17.5L19 7.5" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<h3 class="success-title">{{ t('bet.outright_success') }}</h3>
<div class="hero hero--compact">
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
<p class="team">{{ pick.teamName }}</p>
<span class="odds-badge">@ {{ formatOdds(pick.odds) }}</span>
<div class="hero-info">
<p class="team">{{ pick.teamName }}</p>
<p class="event-title">{{ pick.eventTitle }}</p>
</div>
<span class="odds-badge odds-badge--success">{{ formatOdds(pick.odds) }}</span>
</div>
<p class="event-title">{{ pick.eventTitle }}</p>
<div class="summary">
<div class="summary-row">
<span>{{ t('bet.outright_stake_amount') }}</span>
<span>{{ formatMoney(successStake, locale) }}</span>
<span class="summary-label">{{ t('bet.outright_stake_amount') }}</span>
<span class="summary-value">{{ formatMoney(successStake, locale) }}</span>
</div>
<div class="summary-row">
<span>{{ t('bet.outright_balance') }}</span>
<span class="balance-value">{{ formatMoney(successBalance, locale) }}</span>
<span class="summary-label">{{ t('bet.outright_balance') }}</span>
<span class="summary-value balance-value">{{ formatMoney(successBalance, locale) }}</span>
</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>
</div>
</div>
</Transition>
<BetSuccessOverlay :show="showSuccess" @done="showSuccess = false" />
</template>
<style scoped>
/* ── Transition ── */
.modal-fade-enter-active {
transition: opacity 0.25s ease;
}
.modal-fade-enter-active .modal {
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.25s ease;
}
.modal-fade-enter-from {
opacity: 0;
}
.modal-fade-enter-from .modal {
transform: scale(0.92) translateY(12px);
opacity: 0;
}
/* ── Overlay ── */
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.72);
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
backdrop-filter: blur(4px);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* ── Modal ── */
.modal {
position: relative;
width: 100%;
max-width: 300px;
background: var(--gradient-card);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius);
padding: 18px 16px 16px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px 16px 16px;
text-align: center;
box-shadow: var(--shadow), var(--glow-gold);
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
}
/* ── Close button ── */
.close-x {
position: absolute;
top: 10px;
@@ -398,31 +436,54 @@ function formatOdds(odds: string) {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
background: var(--bg-elevated);
color: var(--text-muted);
font-size: 14px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s, background 0.15s;
}
.close-x:hover {
color: var(--text);
background: var(--bg-hover);
}
/* ── Header ── */
.modal-header {
margin-bottom: 14px;
}
.hint {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 12px;
letter-spacing: 0.04em;
}
/* ── Hero ── */
.hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 10px;
margin-bottom: 12px;
padding: 10px 8px;
border-radius: 8px;
background: rgba(244, 162, 97, 0.04);
border: 1px solid rgba(244, 162, 97, 0.1);
}
.hero--compact {
margin-top: 4px;
margin-bottom: 8px;
margin-bottom: 10px;
}
.hero-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.flag {
@@ -430,25 +491,41 @@ function formatOdds(odds: string) {
height: 32px;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
}
.team {
font-size: 20px;
font-weight: 900;
color: var(--primary-light);
color: var(--text);
letter-spacing: 0.02em;
line-height: 1.2;
}
/* ── Odds badge ── */
.odds-badge {
display: inline-block;
padding: 3px 10px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid var(--border-gold-soft);
color: var(--text);
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background: var(--warning);
border: 1px solid rgba(255, 179, 0, 0.3);
color: #FFFFFF;
font-size: 13px;
font-weight: 800;
border-radius: 4px;
border-radius: 5px;
white-space: nowrap;
}
.odds-old {
text-decoration: line-through;
opacity: 0.65;
font-size: 11px;
}
.odds-arrow {
opacity: 0.7;
font-size: 11px;
}
.odds-badge--up {
@@ -466,6 +543,12 @@ function formatOdds(odds: string) {
border-color: rgba(255, 184, 77, 0.35);
}
.odds-badge--success {
background: rgba(22, 101, 52, 0.85);
border-color: rgba(110, 231, 160, 0.3);
}
/* ── Odds warning ── */
.odds-warning {
margin: 0 0 10px;
padding: 8px 10px;
@@ -477,23 +560,23 @@ function formatOdds(odds: string) {
line-height: 1.45;
}
/* ── Event title ── */
.event-title {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 12px;
line-height: 1.4;
padding: 0 4px;
}
/* ── Balance ── */
.balance-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
margin-bottom: 12px;
background: var(--bg-body);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: 8px;
}
.balance-label {
@@ -505,7 +588,12 @@ function formatOdds(odds: string) {
.balance-value {
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
}
/* ── Stake section ── */
.stake-section {
margin-bottom: 12px;
}
.stake-label {
@@ -517,66 +605,90 @@ function formatOdds(odds: string) {
margin-bottom: 6px;
}
.stake-input-wrap {
position: relative;
margin-bottom: 8px;
}
.stake-input {
width: 100%;
padding: 11px 12px;
margin-bottom: 8px;
border-radius: var(--radius-sm);
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-body);
background: var(--bg-input);
color: var(--text);
font-size: 18px;
font-weight: 800;
text-align: center;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.stake-input:focus {
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.25);
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.15);
}
.stake-input::placeholder {
color: var(--text-muted);
font-weight: 500;
font-size: 14px;
opacity: 0.6;
}
/* ── Quick stakes ── */
.quick-stakes {
display: flex;
gap: 6px;
margin-bottom: 10px;
gap: 5px;
}
.chip {
flex: 1;
padding: 6px 4px;
border-radius: var(--radius-sm);
padding: 6px 2px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-card);
background: var(--bg-card-elevated);
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.chip:active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: var(--surface-accent);
border-color: var(--primary);
color: var(--primary);
background: rgba(244, 162, 97, 0.06);
}
.chip-max {
color: var(--primary-light);
border-color: rgba(244, 162, 97, 0.2);
}
/* ── Est return ── */
.est-return {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
margin-bottom: 12px;
border-radius: 6px;
background: rgba(244, 162, 97, 0.06);
border: 1px solid rgba(244, 162, 97, 0.12);
}
.est-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 12px;
font-weight: 600;
}
.est-value {
color: var(--primary-light);
color: var(--primary);
font-weight: 800;
font-size: 15px;
}
/* ── Error ── */
.error {
color: var(--danger);
font-size: 12px;
@@ -584,69 +696,115 @@ function formatOdds(odds: string) {
margin-bottom: 10px;
}
/* ── Actions ── */
.actions {
display: grid;
grid-template-columns: 1fr 1.2fr;
grid-template-columns: 1fr 1.3fr;
gap: 8px;
}
.btn-cancel {
padding: 10px;
border-radius: var(--radius-sm);
background: var(--bg-body);
border-radius: 8px;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
transition: background 0.15s, color 0.15s;
}
.btn-cancel:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text);
}
.btn-confirm {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px;
border-radius: 6px;
border-radius: 8px;
font-size: 14px;
font-weight: 800;
background: var(--gradient-primary);
color: #FFFFFF;
border: none;
transition: opacity 0.2s, transform 0.1s;
}
.btn-confirm:hover:not(:disabled) {
opacity: 0.92;
}
.btn-confirm:active:not(:disabled) {
transform: scale(0.97);
}
.btn-confirm:disabled {
opacity: 0.45;
cursor: default;
}
.btn-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: btn-spin 0.6s linear infinite;
}
@keyframes btn-spin {
to { transform: rotate(360deg); }
}
/* ── Success ── */
.success-icon {
width: 44px;
height: 44px;
margin: 0 auto 8px;
width: 48px;
height: 48px;
margin: 0 auto 10px;
border-radius: 50%;
border: 2px solid var(--success);
background: rgba(46, 204, 113, 0.12);
background: rgba(16, 185, 129, 0.1);
color: var(--success);
font-size: 24px;
font-weight: 900;
line-height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.success-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
margin-bottom: 10px;
color: var(--text);
margin-bottom: 12px;
}
.summary {
margin: 12px 0 14px;
padding: 10px;
background: var(--bg-body);
padding: 10px 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: 8px;
}
.summary-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--text-muted);
padding: 4px 0;
}
.summary-label {
color: var(--text-muted);
}
.summary-value {
color: var(--text);
font-weight: 700;
}
.summary-row + .summary-row {
margin-top: 4px;
padding-top: 8px;
@@ -656,8 +814,214 @@ function formatOdds(odds: string) {
.btn-done {
width: 100%;
padding: 11px;
border-radius: 6px;
border-radius: 8px;
font-size: 14px;
font-weight: 800;
background: var(--gradient-primary);
color: #FFFFFF;
border: none;
transition: opacity 0.2s, transform 0.1s;
}
.btn-done:hover {
opacity: 0.92;
}
.btn-done:active {
transform: scale(0.97);
}
/* ════════════════════════════════════════════
Desktop / PC styles (≥ 768px)
════════════════════════════════════════════ */
@media (min-width: 768px) {
.overlay {
padding: 32px;
}
.modal {
max-width: 420px;
border-radius: 14px;
padding: 24px 24px 20px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(244, 162, 97, 0.06);
}
.close-x {
top: 14px;
right: 14px;
width: 30px;
height: 30px;
}
.modal-header {
margin-bottom: 16px;
}
.hint {
font-size: 13px;
}
/* Hero — horizontal layout on desktop */
.hero {
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 12px 14px;
margin-bottom: 14px;
border-radius: 10px;
}
.flag {
width: 52px;
height: 36px;
flex-shrink: 0;
border-radius: 5px;
}
.hero-info {
flex: 1;
align-items: flex-start;
min-width: 0;
gap: 3px;
}
.team {
font-size: 18px;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.event-title {
font-size: 11px;
text-align: left;
}
.odds-badge {
flex-shrink: 0;
font-size: 14px;
padding: 5px 14px;
border-radius: 6px;
}
.odds-warning {
margin-bottom: 12px;
padding: 9px 12px;
font-size: 12px;
border-radius: 8px;
}
.balance-row {
padding: 10px 14px;
margin-bottom: 14px;
border-radius: 10px;
}
.balance-label {
font-size: 13px;
}
.balance-value {
font-size: 15px;
}
.stake-section {
margin-bottom: 14px;
}
.stake-label {
font-size: 12px;
margin-bottom: 8px;
}
.stake-input-wrap {
margin-bottom: 10px;
}
.stake-input {
padding: 12px 14px;
font-size: 20px;
border-radius: 10px;
}
.quick-stakes {
gap: 6px;
}
.chip {
padding: 8px 4px;
font-size: 12px;
border-radius: 8px;
cursor: pointer;
}
.chip:hover {
border-color: rgba(244, 162, 97, 0.4);
color: var(--primary-light);
background: rgba(244, 162, 97, 0.06);
}
.est-return {
padding: 10px 14px;
margin-bottom: 14px;
border-radius: 8px;
}
.est-label {
font-size: 13px;
}
.est-value {
font-size: 17px;
}
.actions {
gap: 10px;
}
.btn-cancel,
.btn-confirm {
padding: 12px;
font-size: 15px;
border-radius: 10px;
cursor: pointer;
}
/* Success state desktop */
.success-icon {
width: 56px;
height: 56px;
margin-bottom: 12px;
}
.success-icon svg {
width: 28px;
height: 28px;
}
.success-title {
font-size: 18px;
margin-bottom: 14px;
}
.summary {
margin: 14px 0 16px;
padding: 12px 14px;
border-radius: 10px;
}
.summary-row {
font-size: 14px;
}
.btn-done {
padding: 13px;
font-size: 15px;
border-radius: 10px;
cursor: pointer;
}
}
</style>

View File

@@ -55,7 +55,7 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
</script>
<template>
<section class="event-block" :class="{ expanded }">
<section class="event-block">
<button type="button" class="event-head" :class="{ 'is-expanded': expanded, 'is-settled': isSettled }" :aria-expanded="expanded" @click="emit('toggle')">
<span class="toggle-icon" :class="{ open: expanded }">
<span class="toggle-mark">{{ expanded ? '' : '+' }}</span>
@@ -97,9 +97,10 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
isolation: isolate;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: 8px;
background: var(--bg-card);
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.event-head {
@@ -110,7 +111,7 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
align-items: center;
gap: 10px;
padding: 12px 16px;
background: var(--bg-elevated);
background: none;
border: none;
border-radius: 0;
text-align: left;
@@ -122,7 +123,7 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
position: absolute;
inset: 0;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
opacity: 0.25;
opacity: 0.04;
z-index: -1;
pointer-events: none;
}
@@ -132,8 +133,8 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border-gold-soft);
background: var(--bg-elevated);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
@@ -141,11 +142,11 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
}
.toggle-icon.open {
border-color: var(--primary-light);
border-color: var(--primary);
}
.toggle-mark {
color: var(--primary-light);
color: var(--primary);
font-size: 17px;
font-weight: 900;
line-height: 1;
@@ -170,8 +171,8 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
color: var(--primary-light);
border: 1px solid color-mix(in srgb, var(--primary-light) 45%, transparent);
color: var(--warning);
border: 1px solid color-mix(in srgb, var(--warning) 45%, transparent);
border-radius: 999px;
padding: 1px 7px;
line-height: 1.4;
@@ -180,7 +181,7 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
.event-title {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
line-height: 1.35;
}
@@ -195,7 +196,6 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
opacity: 0.85;
}
.event-saishi {
@@ -212,8 +212,7 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
max-height: 408px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 12px 8px 8px;
background: var(--bg-card);
padding: 8px 2px 2px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}

View File

@@ -85,7 +85,7 @@ onUnmounted(() => {
width: 100%;
min-width: 0;
padding: 8px 4px 6px;
border-radius: var(--radius-sm);
border-radius: 8px;
background: var(--bg-card);
border: 1px solid var(--border);
display: flex;
@@ -94,18 +94,12 @@ onUnmounted(() => {
gap: 4px;
text-align: center;
overflow: hidden;
}
.option-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, transparent 50%, rgba(0, 51, 102, 0.08) 100%);
pointer-events: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.option-card:active {
border-color: var(--border-gold-soft);
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(244, 162, 97, 0.12);
}
.option-card--disabled {
@@ -115,11 +109,12 @@ onUnmounted(() => {
.option-card--disabled:active {
border-color: var(--border);
box-shadow: none;
}
.option-card--winner {
border-color: color-mix(in srgb, var(--primary-light) 75%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary-light) 25%, transparent);
border-color: color-mix(in srgb, var(--warning) 75%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--warning) 25%, transparent);
}
.flag {
@@ -142,7 +137,7 @@ onUnmounted(() => {
.name {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
color: var(--text);
line-height: 1.2;
width: 100%;
overflow: hidden;
@@ -153,6 +148,6 @@ onUnmounted(() => {
.odds {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
color: var(--warning);
}
</style>

View File

@@ -174,7 +174,7 @@ function closeModal() {
<style scoped>
.outright-panel {
padding: 4px 0 0;
padding: 4px 12px 0;
}
.panel-summary {
@@ -190,11 +190,11 @@ function closeModal() {
margin: 0 0 12px;
padding: 8px 10px;
border-radius: 8px;
background: color-mix(in srgb, var(--primary-light) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-light) 22%, transparent);
background: color-mix(in srgb, var(--warning) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--warning) 22%, transparent);
font-size: 12px;
font-weight: 600;
color: var(--primary-light);
color: var(--warning);
line-height: 1.45;
}

View File

@@ -10,35 +10,32 @@ const placement = ref<'top' | 'bottom'>('bottom');
let closeTimer: number | undefined = undefined;
export function useDesktopBetPopover() {
function openAt(event: MouseEvent, item: SlipItem) {
function openAt(event: MouseEvent | undefined, item: SlipItem) {
if (!event) return;
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
const popW = 172;
const popH = 155;
const pad = 12;
// 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') {
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';
}
// 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));
}

View File

@@ -1,42 +0,0 @@
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,
};
}

View File

@@ -27,11 +27,19 @@ export default {
copy: 'Copy',
copy_success: 'Copied',
},
pagination: {
total: '{total} total',
per_page: '{size} / page',
nav: {
home: 'Home',
bet: 'Bet',
bet_history: 'History',
wallet: 'Wallet',
profile: 'Profile',
sports: 'Sports',
football: 'Football',
account_hub: 'Bets & Wallet',
search: 'Search teams, leagues…',
announcements: 'Announcements',
sports_more_soon: 'More sports coming soon',
},
nav: { home: 'Home', sports: 'Sports', football: 'Football', bet: 'Bet', bet_history: 'History', wallet: 'Transactions', account_hub: 'Bets & Wallet', profile: 'Profile', search: 'Search teams, leagues…', announcements: 'Announcements', sports_more_soon: 'More sports coming soon' },
home: {
hot_matches: 'Hot matches',
hot_tab: 'Hot',
@@ -39,8 +47,7 @@ export default {
no_matches: 'No matches',
upcoming_empty: 'No matches kicking off in the next 3 days',
announcement_badge: 'Notice',
announcement_default:
'Welcome to TheBet365 · Football events are live · Bet responsibly',
announcement_default: 'Welcome to TheBet365 · Football events are live · Bet responsibly',
banner_prev: 'Previous slide',
banner_next: 'Next slide',
banner_slide: 'Slide {n}',
@@ -64,7 +71,6 @@ export default {
empty: 'No announcements yet',
not_found: 'This announcement is unavailable',
view_all: 'View all announcements',
select_hint: 'Select an announcement to read',
type_notice: 'Notice',
type_ticker: 'Ticker',
type_banner: 'Banner',
@@ -72,6 +78,7 @@ export default {
go_link: 'Go to page',
open_link: 'Open link',
back: 'Back',
select_hint: 'Select an announcement to read',
center_desc: 'Platform notices, promotions, and maintenance updates',
total_count: '{count} total',
read_more: 'Read more',
@@ -96,7 +103,6 @@ export default {
not_found: 'Message not found',
view_all: 'Back to inbox',
back: 'Back',
select_hint: 'Select a message to read',
mark_all_read: 'Mark all read',
load_more: 'Load more',
delete: 'Delete',
@@ -116,6 +122,7 @@ export default {
view_recharge_history: 'View recharge history',
unread_badge: '{count} unread',
open_inbox: 'Open inbox',
select_hint: 'Select a message to read',
tab_inbox: 'Inbox',
tab_support: 'Support',
},
@@ -155,9 +162,6 @@ export default {
filter_lost: 'Lost',
filter_pending: 'Pending',
filter_push: 'Push',
filter_status_group: 'Settlement',
filter_push_group: 'Special',
filter_push_hint: 'Push: stake refunded on a draw line',
stats_total: 'Total',
stats_won: 'Won',
stats_lost: 'Lost',
@@ -169,10 +173,12 @@ export default {
profit: 'Profit',
return_incl_stake: 'Return {amount} (incl. stake)',
stake_to_return: '{stake} × {odds} = {amount}',
filter_status_group: 'Settlement',
filter_push_group: 'Special',
filter_push_hint: 'Push: stake refunded on a draw line',
},
auth:
{ login: 'Login',
logging_in: 'Logging in…',
auth: {
login: 'Login',
register: 'Create Account',
logout: 'Log out',
username: 'Username',
@@ -231,6 +237,7 @@ export default {
reset_failed: 'Reset failed. Please try again.',
phone_not_registered: 'This phone number is not registered',
back_to_login: 'Back to login',
logging_in: 'Logging in…',
},
support: {
short: 'Support',
@@ -291,6 +298,9 @@ export default {
detail_bet_link: 'View Bet',
detail_tx_id: 'Transaction ID',
detail_not_found: 'Transaction not found',
all_records: 'All Records',
frozen_before: 'Frozen Before',
frozen_after: 'Frozen After',
ref_bet: 'Bet',
ref_deposit: 'Deposit',
ref_withdraw: 'Withdraw',
@@ -316,7 +326,6 @@ export default {
},
recharge: {
title: 'Recharge',
scan_to_pay: 'Scan QR code to pay',
history: 'History',
history_title: 'Recharge History',
bank_transfer: 'Bank Transfer',
@@ -370,6 +379,7 @@ export default {
reapply: 'Re-apply',
reapply_hint: 'You are resubmitting this order. Transfer again and upload a new screenshot.',
back_to_history: 'Back to history',
scan_to_pay: 'Scan QR code to pay',
order_no: 'Order no.',
method: 'Method',
amount: 'Amount',
@@ -401,38 +411,11 @@ export default {
stake: 'Stake',
place_bet: 'Place Bet',
place_bet_short: 'Bet',
quick_bet: 'Quick Bet',
popular_teams: 'Popular Teams',
view_all_markets: 'View All Markets',
all: 'All',
parlay: 'Parlay',
tab_matches: 'Matches',
tab_outright: 'Outright',
sport_football: 'Football',
sport_basketball: 'Basketball',
sport_tennis: 'Tennis',
sport_coming_soon: 'Coming soon',
tab_today: 'Today',
tab_early: 'Early',
filter_time: 'Time',
filter_league: 'Leagues',
filter_market: 'Markets',
time_all: 'All',
time_today: 'Today',
time_early: 'Early',
market_all: 'All markets',
market_handicap_ou: 'Handicap / O-U',
correct_score: 'Correct score',
no_markets: 'No markets available',
no_markets_filtered: 'No markets match the current filter',
no_market_odds: 'No odds available',
popover_title: 'Confirm bet',
place_now: 'Bet now',
add_to_single: 'Add to singles',
add_to_parlay: 'Add to parlay',
slip_parlay_added_short: 'In parlay · View',
add_to_single_list: 'Add to singles',
add_to_parlay_list: 'Add to parlay',
filter_status_all: 'All',
filter_status_open: 'Open',
filter_status_settled: 'Settled',
@@ -467,7 +450,6 @@ export default {
outright_load_more: 'Load more',
outright_settled: 'Settled',
outright_settled_hint: 'This event is settled. Odds and results are view-only.',
outright_view_all: 'View All Teams',
cancel: 'Cancel',
parlay_max_legs: 'Parlay allows up to 5 legs',
parlay_block_outright: 'Outright cannot be parlayed',
@@ -545,37 +527,24 @@ export default {
slip_pick_hint: 'Tap to add/remove from slip; gold border = selected',
picked_tag: 'Selected',
pick_added: 'Added to bet slip',
added_to_single: 'Added to singles list',
added_to_parlay: 'Added to parlay list',
pick_removed: 'Removed from bet slip',
slip_bar_ready: '1 selection',
slip_bar_go: 'Bet slip',
cs_top_hint: '① Enter stake ② Tap Confirm bet above',
slip_empty_hint: 'Tap odds to open the bet drawer',
slip_clear: 'Clear all',
slip_remove: 'Remove',
slip_locate: 'Locate odds',
slip_locate_aria: 'Scroll to this selection in the odds list',
slip_est_return: 'Est. max payout',
slip_parlay_odds: 'Combined odds {odds}',
slip_review_title: 'Review selection',
slip_balance: 'Account balance',
slip_tab_single: 'Single',
slip_tab_parlay: 'Parlay',
slip_tab_history: 'History',
slip_history_all: 'All',
slip_history_match: 'This match',
slip_history_view_all: 'View all',
slip_history_load_more: 'Load more',
slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay',
slip_add_parlay: 'Add to parlay',
slip_parlay_only_hint: 'This market is parlay-only. Tap Add to parlay to bet.',
slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.',
slip_parlay_count: '{n} parlay leg(s)',
slip_total_stake: 'Total stake',
slip_place_single: 'Single bet',
slip_place_all: 'Bet all',
slip_parlay_added_btn: 'Added',
slip_currency: 'Amount',
slip_min: 'Min',
slip_min_error: 'Minimum stake is {amount}',
@@ -584,15 +553,53 @@ export default {
accept_changes_place: 'Accept changes & place bet',
odds_was: 'Was',
odds_now: 'Now',
place_confirm_title: 'Confirm bet',
place_confirm_single:
"Selection: {pick} ({'@'}{odds})\nStake: {stake}\nEst. return: {return}\n\nPlace this bet?",
place_confirm_singles:
'{count} single bet(s)\nTotal stake: {stake}\nEst. return: {return}\n\nPlace all?',
place_confirm_parlay:
'Parlay ({count} legs), total odds {odds}\nStake: {stake}\nEst. return: {return}\n\nPlace parlay?',
place_success: 'Bet placed',
place_failed: 'Bet failed',
quick_bet: 'Quick Bet',
popular_teams: 'Popular Teams',
view_all_markets: 'View All Markets',
all: 'All',
sport_football: 'Football',
sport_basketball: 'Basketball',
sport_tennis: 'Tennis',
sport_coming_soon: 'Coming soon',
filter_time: 'Time',
filter_league: 'Leagues',
filter_market: 'Markets',
time_all: 'All',
time_today: 'Today',
time_early: 'Early',
market_all: 'All markets',
market_handicap_ou: 'Handicap / O-U',
correct_score: 'Correct score',
no_markets: 'No markets available',
no_markets_filtered: 'No markets match the current filter',
no_market_odds: 'No odds available',
popover_title: 'Confirm bet',
place_now: 'Bet now',
add_to_single: 'Add to singles',
add_to_parlay: 'Add to parlay',
slip_parlay_added_short: 'In parlay · View',
add_to_single_list: 'Add to singles',
add_to_parlay_list: 'Add to parlay',
outright_view_all: 'View All Teams',
added_to_single: 'Added to singles list',
added_to_parlay: 'Added to parlay list',
slip_clear: 'Clear all',
slip_locate: 'Locate odds',
slip_locate_aria: 'Scroll to this selection in the odds list',
slip_tab_history: 'History',
slip_history_all: 'All',
slip_history_match: 'This match',
slip_history_view_all: 'View all',
slip_history_load_more: 'Load more',
slip_place_single: 'Single bet',
slip_place_all: 'Bet all',
slip_parlay_added_btn: 'Added',
place_confirm_title: 'Confirm bet',
place_confirm_single: 'Selection: {pick} ({\'@\'}{odds})\nStake: {stake}\nEst. return: {return}\n\nPlace this bet?',
place_confirm_singles: '{count} single bet(s)\nTotal stake: {stake}\nEst. return: {return}\n\nPlace all?',
place_confirm_parlay: 'Parlay ({count} legs), total odds {odds}\nStake: {stake}\nEst. return: {return}\n\nPlace parlay?',
bet_no: 'Bet no.',
match: 'Match',
selection: 'Selection',
@@ -604,15 +611,6 @@ export default {
},
profile: {
edit: 'Edit Profile',
edit_title: 'Edit profile',
settings: 'Settings',
save_success: 'Saved successfully',
basic_info: 'Basic info',
change_avatar: 'Change avatar',
username: 'Username',
username_locked: 'Username cannot be changed',
account: 'Account',
quick_nav: 'Quick links',
language: 'Language',
avatar: 'Avatar',
avatar_change: 'Change avatar',
@@ -657,5 +655,17 @@ 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.',
edit_title: 'Edit profile',
save_success: 'Saved successfully',
basic_info: 'Basic info',
change_avatar: 'Change avatar',
username: 'Username',
username_locked: 'Username cannot be changed',
account: 'Account',
quick_nav: 'Quick links',
},
pagination: {
total: '{total} total',
per_page: '{size} / page',
},
} as const;

View File

@@ -27,19 +27,15 @@ export default {
copy: 'Salin',
copy_success: 'Berjaya disalin',
},
pagination: {
total: '{total} jumlah',
per_page: '{size} / halaman',
},
nav: {
home: 'Laman Utama',
sports: 'Sukan',
football: 'Bola Sepak',
bet: 'Pertaruhan',
bet_history: 'Sejarah',
wallet: 'Transaksi',
account_hub: 'Pertaruhan & Bil',
wallet: 'Bil',
profile: 'Profil',
sports: 'Sukan',
football: 'Bola Sepak',
account_hub: 'Pertaruhan & Bil',
search: 'Cari pasukan, liga…',
announcements: 'Pengumuman',
sports_more_soon: 'Lebih banyak sukan akan datang',
@@ -51,8 +47,7 @@ export default {
no_matches: 'Tiada perlawanan',
upcoming_empty: 'Tiada perlawanan dalam 3 hari akan datang',
announcement_badge: 'Notis',
announcement_default:
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
announcement_default: 'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
banner_prev: 'Slaid sebelumnya',
banner_next: 'Slaid seterusnya',
banner_slide: 'Slaid {n}',
@@ -76,7 +71,6 @@ export default {
empty: 'Tiada pengumuman',
not_found: 'Pengumuman tidak tersedia',
view_all: 'Lihat semua pengumuman',
select_hint: 'Pilih pengumuman untuk dibaca',
type_notice: 'Notis',
type_ticker: 'Ticker',
type_banner: 'Banner',
@@ -84,6 +78,7 @@ export default {
go_link: 'Pergi ke halaman',
open_link: 'Buka pautan',
back: 'Kembali',
select_hint: 'Pilih pengumuman untuk dibaca',
center_desc: 'Notis platform, promosi dan penyelenggaraan',
total_count: '{count} jumlah',
read_more: 'Baca lagi',
@@ -108,7 +103,6 @@ export default {
not_found: 'Mesej tidak dijumpai',
view_all: 'Kembali ke peti mesej',
back: 'Kembali',
select_hint: 'Pilih mesej untuk dibaca',
mark_all_read: 'Tanda semua dibaca',
load_more: 'Muat lagi',
delete: 'Padam',
@@ -128,6 +122,7 @@ export default {
view_recharge_history: 'Lihat sejarah deposit',
unread_badge: '{count} belum dibaca',
open_inbox: 'Buka peti mesej',
select_hint: 'Pilih mesej untuk dibaca',
tab_inbox: 'Peti masuk',
tab_support: 'Sokongan',
},
@@ -167,9 +162,6 @@ export default {
filter_lost: 'Kalah',
filter_pending: 'Menunggu',
filter_push: 'Seri',
filter_status_group: 'Penyelesaian',
filter_push_group: 'Khas',
filter_push_hint: 'Seri: jumlah dikembalikan pada garis seri',
stats_total: 'Jumlah',
stats_won: 'Menang',
stats_lost: 'Kalah',
@@ -181,10 +173,12 @@ export default {
profit: 'Keuntungan',
return_incl_stake: 'Pulangan {amount} (termasuk jumlah)',
stake_to_return: '{stake} × {odds} = {amount}',
filter_status_group: 'Penyelesaian',
filter_push_group: 'Khas',
filter_push_hint: 'Seri: jumlah dikembalikan pada garis seri',
},
auth: {
login: 'Log Masuk',
logging_in: 'Sedang log masuk…',
register: 'Daftar Akaun',
logout: 'Log Keluar',
username: 'Nama Pengguna',
@@ -243,6 +237,7 @@ export default {
reset_failed: 'Gagal menetapkan semula. Sila cuba lagi.',
phone_not_registered: 'Nombor telefon ini belum didaftarkan',
back_to_login: 'Kembali ke log masuk',
logging_in: 'Sedang log masuk…',
},
support: {
short: 'Sokongan',
@@ -303,6 +298,9 @@ export default {
detail_bet_link: 'Lihat Pertaruhan',
detail_tx_id: 'ID Transaksi',
detail_not_found: 'Rekod tidak dijumpai',
all_records: 'Semua Rekod',
frozen_before: 'Beku Sebelum',
frozen_after: 'Beku Selepas',
ref_bet: 'Pertaruhan',
ref_deposit: 'Deposit',
ref_withdraw: 'Pengeluaran',
@@ -328,7 +326,6 @@ export default {
},
recharge: {
title: 'Topup',
scan_to_pay: 'Imbas kod QR untuk bayar',
history: 'Sejarah',
history_title: 'Sejarah Topup',
bank_transfer: 'Pindahan Bank',
@@ -382,6 +379,7 @@ export default {
reapply: 'Mohon semula',
reapply_hint: 'Anda menghantar semula pesanan ini. Buat pindahan baharu dan muat naik tangkapan skrin baharu.',
back_to_history: 'Kembali ke sejarah',
scan_to_pay: 'Imbas kod QR untuk bayar',
order_no: 'No. pesanan',
method: 'Kaedah',
amount: 'Amaun',
@@ -413,38 +411,11 @@ export default {
stake: 'Jumlah',
place_bet: 'Letak Pertaruhan',
place_bet_short: 'Pertaruhan',
quick_bet: 'Pertaruhan Pantas',
popular_teams: 'Pasukan Popular',
view_all_markets: 'Lihat Semua Pasaran',
all: 'Semua',
parlay: 'Berganda',
tab_matches: 'Perlawanan',
tab_outright: 'Juara',
sport_football: 'Bola Sepak',
sport_basketball: 'Bola Keranjang',
sport_tennis: 'Tenis',
sport_coming_soon: 'Akan datang',
tab_today: 'Hari Ini',
tab_early: 'Awal',
filter_time: 'Masa',
filter_league: 'Liga',
filter_market: 'Pasaran',
time_all: 'Semua',
time_today: 'Hari ini',
time_early: 'Awal',
market_all: 'Semua pasaran',
market_handicap_ou: 'Handicap / O-U',
correct_score: 'Skor tepat',
no_markets: 'Tiada pasaran tersedia',
no_markets_filtered: 'Tiada pasaran sepadan dengan penapis semasa',
no_market_odds: 'Tiada odds pasaran',
popover_title: 'Sahkan pertaruhan',
place_now: 'Pertaruhan sekarang',
add_to_single: 'Tambah tunggal',
add_to_parlay: 'Tambah parlay',
slip_parlay_added_short: 'Dalam parlay · Lihat',
add_to_single_list: 'Tambah ke senarai tunggal',
add_to_parlay_list: 'Tambah ke berganda',
filter_status_all: 'Semua',
filter_status_open: 'Buka',
filter_status_settled: 'Selesai',
@@ -479,7 +450,6 @@ export default {
outright_load_more: 'Muat lagi',
outright_settled: 'Selesai',
outright_settled_hint: 'Acara ini telah diselesaikan. Hanya paparan odds dan keputusan.',
outright_view_all: 'Lihat Semua Pasukan',
cancel: 'Batal',
parlay_max_legs: 'Maksimum 5 pilihan parlay',
parlay_block_outright: 'Outright tidak boleh parlay',
@@ -557,37 +527,24 @@ export default {
slip_pick_hint: 'Ketik untuk tambah/buang; sisi emas = dipilih',
picked_tag: 'Dipilih',
pick_added: 'Ditambah ke slip',
added_to_single: 'Ditambah ke senarai tunggal',
added_to_parlay: 'Ditambah ke senarai parlay',
pick_removed: 'Dikeluarkan dari slip',
slip_bar_ready: '1 pilihan',
slip_bar_go: 'Buka slip',
cs_top_hint: '① Isi jumlah ② Ketik Sahkan di atas',
slip_empty_hint: 'Ketik odds untuk buka laci pertaruhan',
slip_clear: 'Kosongkan semua',
slip_remove: 'Buang',
slip_locate: 'Lokasi odds',
slip_locate_aria: 'Tatal ke pilihan ini dalam senarai odds',
slip_est_return: 'Anggaran bayaran maksimum',
slip_parlay_odds: 'Odds gabungan {odds}',
slip_review_title: 'Semak pilihan',
slip_balance: 'Baki akaun',
slip_tab_single: 'Tunggal',
slip_tab_parlay: 'Parlay',
slip_tab_history: 'Sejarah',
slip_history_all: 'Semua',
slip_history_match: 'Perlawanan ini',
slip_history_view_all: 'Lihat semua',
slip_history_load_more: 'Muat lagi',
slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay',
slip_add_parlay: 'Tambah ke parlay',
slip_parlay_only_hint: 'Pasaran ini hanya parlay. Ketik Tambah ke parlay untuk bertaruh.',
slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.',
slip_parlay_count: '{n} pilihan parlay',
slip_total_stake: 'Jumlah pertaruhan',
slip_place_single: 'Taruhan tunggal',
slip_place_all: 'Taruhan semua',
slip_parlay_added_btn: 'Ditambah',
slip_currency: 'Amaun',
slip_min: 'Min',
slip_min_error: 'Jumlah minimum ialah {amount}',
@@ -596,15 +553,53 @@ export default {
accept_changes_place: 'Terima perubahan & pertaruh',
odds_was: 'Asal',
odds_now: 'Baharu',
place_confirm_title: 'Sahkan pertaruhan',
place_confirm_single:
"Pilihan: {pick} ({'@'}{odds})\nJumlah: {stake}\nAnggaran pulangan: {return}\n\nSahkan pertaruhan ini?",
place_confirm_singles:
'{count} pertaruhan tunggal\nJumlah keseluruhan: {stake}\nAnggaran pulangan: {return}\n\nSahkan semua?',
place_confirm_parlay:
'Parlay {count} perlawanan, odds {odds}\nJumlah: {stake}\nAnggaran pulangan: {return}\n\nSahkan parlay?',
place_success: 'Pertaruhan berjaya',
place_failed: 'Pertaruhan gagal',
quick_bet: 'Pertaruhan Pantas',
popular_teams: 'Pasukan Popular',
view_all_markets: 'Lihat Semua Pasaran',
all: 'Semua',
sport_football: 'Bola Sepak',
sport_basketball: 'Bola Keranjang',
sport_tennis: 'Tenis',
sport_coming_soon: 'Akan datang',
filter_time: 'Masa',
filter_league: 'Liga',
filter_market: 'Pasaran',
time_all: 'Semua',
time_today: 'Hari ini',
time_early: 'Awal',
market_all: 'Semua pasaran',
market_handicap_ou: 'Handicap / O-U',
correct_score: 'Skor tepat',
no_markets: 'Tiada pasaran tersedia',
no_markets_filtered: 'Tiada pasaran sepadan dengan penapis semasa',
no_market_odds: 'Tiada odds pasaran',
popover_title: 'Sahkan pertaruhan',
place_now: 'Pertaruhan sekarang',
add_to_single: 'Tambah tunggal',
add_to_parlay: 'Tambah parlay',
slip_parlay_added_short: 'Dalam parlay · Lihat',
add_to_single_list: 'Tambah ke senarai tunggal',
add_to_parlay_list: 'Tambah ke berganda',
outright_view_all: 'Lihat Semua Pasukan',
added_to_single: 'Ditambah ke senarai tunggal',
added_to_parlay: 'Ditambah ke senarai parlay',
slip_clear: 'Kosongkan semua',
slip_locate: 'Lokasi odds',
slip_locate_aria: 'Tatal ke pilihan ini dalam senarai odds',
slip_tab_history: 'Sejarah',
slip_history_all: 'Semua',
slip_history_match: 'Perlawanan ini',
slip_history_view_all: 'Lihat semua',
slip_history_load_more: 'Muat lagi',
slip_place_single: 'Taruhan tunggal',
slip_place_all: 'Taruhan semua',
slip_parlay_added_btn: 'Ditambah',
place_confirm_title: 'Sahkan pertaruhan',
place_confirm_single: 'Pilihan: {pick} ({\'@\'}{odds})\nJumlah: {stake}\nAnggaran pulangan: {return}\n\nSahkan pertaruhan ini?',
place_confirm_singles: '{count} pertaruhan tunggal\nJumlah keseluruhan: {stake}\nAnggaran pulangan: {return}\n\nSahkan semua?',
place_confirm_parlay: 'Parlay {count} perlawanan, odds {odds}\nJumlah: {stake}\nAnggaran pulangan: {return}\n\nSahkan parlay?',
bet_no: 'No. pertaruhan',
match: 'Perlawanan',
selection: 'Pilihan',
@@ -616,15 +611,6 @@ export default {
},
profile: {
edit: 'Edit Profil',
edit_title: 'Edit profil',
settings: 'Tetapan',
save_success: 'Berjaya disimpan',
basic_info: 'Maklumat asas',
change_avatar: 'Tukar avatar',
username: 'Nama pengguna',
username_locked: 'Nama pengguna tidak boleh diubah',
account: 'Akaun',
quick_nav: 'Pautan pantas',
language: 'Bahasa',
avatar: 'Avatar',
avatar_change: 'Tukar avatar',
@@ -669,5 +655,17 @@ 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.',
edit_title: 'Edit profil',
save_success: 'Berjaya disimpan',
basic_info: 'Maklumat asas',
change_avatar: 'Tukar avatar',
username: 'Nama pengguna',
username_locked: 'Nama pengguna tidak boleh diubah',
account: 'Akaun',
quick_nav: 'Pautan pantas',
},
pagination: {
total: '{total} jumlah',
per_page: '{size} / halaman',
},
} as const;

View File

@@ -27,11 +27,19 @@ export default {
copy: '复制',
copy_success: '复制成功',
},
pagination: {
total: '共 {total} 条',
per_page: '{size} / 页',
nav: {
home: '主页',
bet: '投注',
bet_history: '历史投注',
wallet: '账单',
profile: '我的',
sports: '体育赛事',
football: '足球',
account_hub: '投注账单',
search: '搜索球队、联赛…',
announcements: '公告',
sports_more_soon: '更多体育,敬请期待',
},
nav: { home: '主页', sports: '体育赛事', football: '足球', bet: '投注', bet_history: '历史投注', wallet: '资金明细', account_hub: '投注账单', profile: '我的', search: '搜索球队、联赛…', announcements: '公告', sports_more_soon: '更多体育,敬请期待' },
home: {
hot_matches: '热门赛事',
hot_tab: '热门',
@@ -39,8 +47,7 @@ export default {
no_matches: '暂无赛事',
upcoming_empty: '未来 3 天内暂无赛事',
announcement_badge: '公告',
announcement_default:
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
announcement_default: '欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
banner_prev: '上一张',
banner_next: '下一张',
banner_slide: '第 {n} 张',
@@ -64,7 +71,6 @@ export default {
empty: '暂无公告',
not_found: '公告不存在或已下线',
view_all: '查看全部公告',
select_hint: '请选择一条公告查看详情',
type_notice: '公告',
type_ticker: '跑马灯',
type_banner: 'Banner',
@@ -72,6 +78,7 @@ export default {
go_link: '前往页面',
open_link: '打开链接',
back: '返回',
select_hint: '请选择一条公告查看详情',
center_desc: '平台公告、活动与维护通知一览',
total_count: '共 {count} 条',
read_more: '查看详情',
@@ -96,7 +103,6 @@ export default {
not_found: '消息不存在',
view_all: '返回消息列表',
back: '返回',
select_hint: '请选择一条消息查看详情',
mark_all_read: '全部已读',
load_more: '加载更多',
delete: '删除',
@@ -116,6 +122,7 @@ export default {
view_recharge_history: '查看充值记录',
unread_badge: '{count} 条未读',
open_inbox: '打开消息中心',
select_hint: '请选择一条消息查看详情',
tab_inbox: '收件箱',
tab_support: '客服',
},
@@ -154,10 +161,7 @@ export default {
filter_won: '已赢',
filter_lost: '已输',
filter_pending: '待定',
filter_push: '走',
filter_status_group: '结算状态',
filter_push_group: '特殊结算',
filter_push_hint: '走水:盘口平局,本金退回',
filter_push: '走',
stats_total: '总投注',
stats_won: '赢',
stats_lost: '输',
@@ -169,10 +173,12 @@ export default {
profit: '净赢',
return_incl_stake: '回报 {amount}(含本金)',
stake_to_return: '{stake} × {odds} = {amount}',
filter_status_group: '结算状态',
filter_push_group: '特殊结算',
filter_push_hint: '走水:盘口平局,本金退回',
},
auth: {
login: '登录',
logging_in: '登录中…',
register: '注册账号',
logout: '退出登录',
username: '账号',
@@ -231,6 +237,7 @@ export default {
reset_failed: '重置失败,请重试',
phone_not_registered: '该手机号未注册',
back_to_login: '返回登录',
logging_in: '登录中…',
},
support: {
short: '客服',
@@ -291,6 +298,9 @@ export default {
detail_bet_link: '查看注单',
detail_tx_id: '流水号',
detail_not_found: '账单不存在',
all_records: '全部记录',
frozen_before: '变动前冻结',
frozen_after: '变动后冻结',
ref_bet: '投注',
ref_deposit: '充值',
ref_withdraw: '提款',
@@ -316,7 +326,6 @@ export default {
},
recharge: {
title: '充值',
scan_to_pay: '请扫描二维码完成支付',
history: '记录',
history_title: '充值记录',
bank_transfer: '银行转账',
@@ -370,6 +379,7 @@ export default {
reapply: '重新申请',
reapply_hint: '将在原订单上重新提交,请重新转账并上传新的截图。',
back_to_history: '返回充值记录',
scan_to_pay: '请扫描二维码完成支付',
order_no: '订单号',
method: '方式',
amount: '充值金额',
@@ -401,38 +411,11 @@ export default {
stake: '投注金额',
place_bet: '确认下注',
place_bet_short: '下注',
quick_bet: '快速下注',
popular_teams: '热门队伍',
view_all_markets: '查看全部玩法',
all: '全部',
parlay: '串关',
tab_matches: '球赛',
tab_outright: '优胜冠军',
sport_football: '足球',
sport_basketball: '篮球',
sport_tennis: '网球',
sport_coming_soon: '即将上线',
tab_today: '今日',
tab_early: '早盘',
filter_time: '时间筛选',
filter_league: '联赛',
filter_market: '玩法筛选',
time_all: '全部',
time_today: '今天',
time_early: '早盘',
market_all: '所有盘口',
market_handicap_ou: '让球 / 大小',
correct_score: '波胆',
no_markets: '暂无可用盘口',
no_markets_filtered: '当前筛选下暂无盘口',
no_market_odds: '暂无盘口赔率',
popover_title: '确认投注',
place_now: '立即下注',
add_to_single: '加入单关',
add_to_parlay: '加入串关',
slip_parlay_added_short: '已加入串关 · 点击查看',
add_to_single_list: '加入单关列表',
add_to_parlay_list: '加入串关列表',
filter_status_all: '全部',
filter_status_open: '待开赛',
filter_status_settled: '已结束',
@@ -467,7 +450,6 @@ export default {
outright_load_more: '加载更多',
outright_settled: '已结算',
outright_settled_hint: '本赛事已结算,仅可查看赔率与冠军结果',
outright_view_all: '查看全部队伍',
cancel: '取消',
parlay_max_legs: '串关最多 5 项',
parlay_block_outright: '冠军盘不可串关',
@@ -545,37 +527,24 @@ export default {
slip_pick_hint: '点选项加入投注单;金边表示已选,再点一次可取消',
picked_tag: '已选',
pick_added: '已加入投注单',
added_to_single: '已加入单关列表',
added_to_parlay: '已加入串关列表',
pick_removed: '已从投注单移除',
slip_bar_ready: '已选一项',
slip_bar_go: '投注单',
cs_top_hint: '① 在比分格填金额 ② 点上方「确认下注」',
slip_empty_hint: '点击赔率加入单关或串关列表',
slip_clear: '清空',
slip_empty_hint: '点击赔率打开投注抽屉',
slip_remove: '移除',
slip_locate: '定位到盘口',
slip_locate_aria: '定位到该选项在盘口列表中的位置',
slip_est_return: '预计最大领取金额',
slip_parlay_odds: '组合赔率 {odds}',
slip_review_title: '确认下注内容',
slip_balance: '账户余额',
slip_tab_single: '单注',
slip_tab_parlay: '串关',
slip_tab_history: '历史',
slip_history_all: '全部',
slip_history_match: '本场赛事',
slip_history_view_all: '查看全部',
slip_history_load_more: '加载更多',
slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」',
slip_add_parlay: '加入串关',
slip_parlay_only_hint: '此盘口不支持单关,请点「加入串关」后再投注',
slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。',
slip_parlay_count: '{n} 项串关',
slip_total_stake: '总投注金额',
slip_place_single: '单独下注',
slip_place_all: '全部下注',
slip_parlay_added_btn: '已加入',
slip_currency: '金额',
slip_min: '最低',
slip_min_error: '最低投注金额为 {amount}',
@@ -584,15 +553,53 @@ export default {
accept_changes_place: '接受变更并下注',
odds_was: '原赔率',
odds_now: '新赔率',
place_confirm_title: '确认下注',
place_confirm_single:
"选项:{pick}{'@'}{odds}\n投注金额{stake}\n预计派彩{return}\n\n确认提交此注单",
place_confirm_singles:
'共 {count} 笔单关\n总投注{stake}\n预计派彩{return}\n\n确认提交',
place_confirm_parlay:
'串关 {count} 场,总赔率 {odds}\n投注金额{stake}\n预计派彩{return}\n\n确认提交',
place_success: '下注成功',
place_failed: '下注失败',
quick_bet: '快速下注',
popular_teams: '热门队伍',
view_all_markets: '查看全部玩法',
all: '全部',
sport_football: '足球',
sport_basketball: '篮球',
sport_tennis: '网球',
sport_coming_soon: '即将上线',
filter_time: '时间筛选',
filter_league: '联赛',
filter_market: '玩法筛选',
time_all: '全部',
time_today: '今天',
time_early: '早盘',
market_all: '所有盘口',
market_handicap_ou: '让球 / 大小',
correct_score: '波胆',
no_markets: '暂无可用盘口',
no_markets_filtered: '当前筛选下暂无盘口',
no_market_odds: '暂无盘口赔率',
popover_title: '确认投注',
place_now: '立即下注',
add_to_single: '加入单关',
add_to_parlay: '加入串关',
slip_parlay_added_short: '已加入串关 · 点击查看',
add_to_single_list: '加入单关列表',
add_to_parlay_list: '加入串关列表',
outright_view_all: '查看全部队伍',
added_to_single: '已加入单关列表',
added_to_parlay: '已加入串关列表',
slip_clear: '清空',
slip_locate: '定位到盘口',
slip_locate_aria: '定位到该选项在盘口列表中的位置',
slip_tab_history: '历史',
slip_history_all: '全部',
slip_history_match: '本场赛事',
slip_history_view_all: '查看全部',
slip_history_load_more: '加载更多',
slip_place_single: '单独下注',
slip_place_all: '全部下注',
slip_parlay_added_btn: '已加入',
place_confirm_title: '确认下注',
place_confirm_single: '选项:{pick}{\'@\'}{odds}\n投注金额{stake}\n预计派彩{return}\n\n确认提交此注单',
place_confirm_singles: '共 {count} 笔单关\n总投注{stake}\n预计派彩{return}\n\n确认提交',
place_confirm_parlay: '串关 {count} 场,总赔率 {odds}\n投注金额{stake}\n预计派彩{return}\n\n确认提交',
bet_no: '注单号',
match: '比赛',
selection: '投注选项',
@@ -604,15 +611,6 @@ export default {
},
profile: {
edit: '修改资料',
edit_title: '修改资料',
settings: '设置',
save_success: '保存成功',
basic_info: '基础信息',
change_avatar: '更换头像',
username: '用户名',
username_locked: '用户名不可修改',
account: '账户信息',
quick_nav: '快捷入口',
language: '语言',
avatar: '选择头像',
avatar_change: '修改头像',
@@ -657,5 +655,17 @@ export default {
rules_p3: '赛果由平台根据官方录入的半场/全场比分结算,结算预览经确认后入账。',
rules_p4: '若本说明与后台公告冲突,以最新公告及实际盘口规则为准。',
rules_p5: '操作步骤:进入任意赛事详情,点右上角「?」查看玩法说明。',
edit_title: '修改资料',
save_success: '保存成功',
basic_info: '基础信息',
change_avatar: '更换头像',
username: '用户名',
username_locked: '用户名不可修改',
account: '账户信息',
quick_nav: '快捷入口',
},
pagination: {
total: '共 {total} 条',
per_page: '{size} / 页',
},
} as const;

View File

@@ -1,194 +1,34 @@
<script setup lang="ts">
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
import { RouterView, RouterLink, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAuthStore } from '../stores/auth';
import { useBetSlipStore } from '../stores/betSlip';
import CashBalanceChip from '../components/CashBalanceChip.vue';
import UserAvatarMenu from '../components/UserAvatarMenu.vue';
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import { useAppLocale } from '../composables/useAppLocale';
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
import BottomNavIcon from '../components/BottomNavIcon.vue';
import BackToTopButton from '../components/BackToTopButton.vue';
import SettingsDrawer from '../components/SettingsDrawer.vue';
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { useViewport } from '../composables/useViewport';
import DesktopShell from '../components/desktop/DesktopShell.vue';
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
const { isDesktop } = useViewport();
import { usePlayerHome } from '../composables/usePlayerHome';
import { useInboxFeature } from '../composables/useInboxFeature';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { startPresencePing, stopPresencePing } from '../composables/usePresencePing';
import { useDepositNotifications } from '../composables/useDepositNotifications';
import { usePlayerMessages } from '../composables/usePlayerMessages';
import { useInboxFeature } from '../composables/useInboxFeature';
import { startPresencePing, stopPresencePing } from '../composables/usePresencePing';
const { isDesktop } = useViewport();
const { t, locale } = useI18n();
const auth = useAuthStore();
const { initFromUser } = useAppLocale();
const route = useRoute();
const router = useRouter();
const slip = useBetSlipStore();
const showSettings = ref(false);
const floatBtnRef = ref<HTMLElement | null>(null);
const btnX = ref<number | null>(null);
const btnY = ref<number | null>(null);
const isDragging = ref(false);
const isDocked = ref(false);
const dockSide = ref<'left' | 'right'>('right');
let startX = 0;
let startY = 0;
let initialX = 0;
let initialY = 0;
let hasMoved = false;
let wasDockedBeforeInteraction = false;
const btnStyle = computed(() => {
if (btnX.value === null || btnY.value === null) {
return {
right: '16px',
bottom: '80px',
};
}
return {
left: `${btnX.value}px`,
top: `${btnY.value}px`,
};
});
const DOCK_OFFSET = 24;
const SNAP_THRESHOLD = 16; // 仅当按é®è´´è¿å±<C3A5>幕边缘时æ‰<C3A6>å<EFBFBD>¸é™„
const DOCK_SNAP_THRESHOLD = 20; // px â€?按é®è¾¹ç¼˜è·<C3A8>å±<C3A5>幕边缘å°<C3A5>于此值æ‰<C3A6>å<EFBFBD>¸é™„
const FLOAT_BTN_SIZE = 48;
const FLOAT_BTN_EDGE_INSET = 8;
function undockToVisualPosition() {
if (!isDocked.value || btnX.value === null) return;
if (dockSide.value === 'right') {
btnX.value += DOCK_OFFSET;
} else {
btnX.value -= DOCK_OFFSET;
}
isDocked.value = false;
}
function snapToEdge(side: 'left' | 'right') {
const screenWidth = window.innerWidth;
dockSide.value = side;
btnX.value = side === 'left' ? FLOAT_BTN_EDGE_INSET : screenWidth - FLOAT_BTN_SIZE - FLOAT_BTN_EDGE_INSET;
isDocked.value = true;
}
function onPointerDown(e: PointerEvent) {
if (e.pointerType === 'mouse' && e.button !== 0) return;
if (isDragging.value) return;
const el = e.currentTarget as HTMLElement;
try {
el.setPointerCapture(e.pointerId);
} catch (err) {
// ignore
}
isDragging.value = true;
hasMoved = false;
wasDockedBeforeInteraction = isDocked.value;
startX = e.clientX;
startY = e.clientY;
// å<>¸é™„æ€<C3A6>ä¸ transform ä¼šæŠŠæŒ‰é®æŽ¨å‡ºå±<C3A5>幕,先还原到å<C2B0>¯è§<C3A8>ä½<C3A4>置,å<C592>¦åˆ™å‡ ä¹Žæ— æ³•触å<C2A6>ææ½
undockToVisualPosition();
const btnSize = FLOAT_BTN_SIZE;
initialX = btnX.value !== null ? btnX.value : window.innerWidth - btnSize - 16;
initialY = btnY.value !== null ? btnY.value : window.innerHeight - btnSize - 80;
btnX.value = initialX;
btnY.value = initialY;
}
function onPointerMove(e: PointerEvent) {
if (!isDragging.value) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!hasMoved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) {
hasMoved = true;
}
if (hasMoved) {
const newX = initialX + dx;
const newY = initialY + dy;
const btnSize = 48;
const minX = -24;
const maxX = window.innerWidth - 24;
const minY = 60;
const maxY = window.innerHeight - btnSize - 60;
btnX.value = Math.max(minX, Math.min(newX, maxX));
btnY.value = Math.max(minY, Math.min(newY, maxY));
}
}
function openInboxHub() {
if (!auth.token) {
auth.showLoginPrompt(hubRoute.value);
return;
}
void router.push(hubRoute.value);
}
function onPointerUp(e: PointerEvent) {
if (!isDragging.value) return;
isDragging.value = false;
const el = e.currentTarget as HTMLElement;
try {
el.releasePointerCapture(e.pointerId);
} catch (err) {
// ignore
}
if (hasMoved) {
if (btnX.value !== null && btnY.value !== null) {
const screenWidth = window.innerWidth;
const btnSize = 48;
const snapThreshold = SNAP_THRESHOLD;
const distLeft = btnX.value;
const distRight = screenWidth - (btnX.value + btnSize);
if (distLeft <= snapThreshold) {
snapToEdge('left');
} else if (distRight <= snapThreshold) {
snapToEdge('right');
} else {
// Float freely in place �no folding
isDocked.value = false;
}
}
} else {
if (wasDockedBeforeInteraction) {
snapToEdge(dockSide.value);
}
openInboxHub();
}
if (btnX.value !== null && btnY.value !== null) {
localStorage.setItem('float_cs_x', String(btnX.value));
localStorage.setItem('float_cs_y', String(btnY.value));
localStorage.setItem('float_cs_side', dockSide.value);
localStorage.setItem('float_cs_docked', isDocked.value ? '1' : '0');
}
}
const { inboxEnabled, hubRoute, hubOpenLabelKey } = useInboxFeature();
const isDetailPage = computed(() => {
const p = route.path;
@@ -198,6 +38,7 @@ const isDetailPage = computed(() => {
p.startsWith('/bets/') ||
p.startsWith('/wallet/') ||
p.startsWith('/messages') ||
p.startsWith('/announcements/') ||
p === '/profile/edit' ||
p === '/profile/cashbacks'
);
@@ -205,7 +46,7 @@ const isDetailPage = computed(() => {
const showHeader = computed(() => !isDetailPage.value);
const showAnnouncement = computed(
() => !isDetailPage.value && !route.path.startsWith('/profile') && !route.path.startsWith('/announcements')
() => !isDetailPage.value && !route.path.startsWith('/profile') && !route.path.startsWith('/announcements'),
);
const showBottomNav = computed(() => {
@@ -225,13 +66,10 @@ const showBottomNav = computed(() => {
return false;
});
const { announcements, announcementItems, load: loadPlayerHome } = usePlayerHome();
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
const { loadProfile, refreshProfile, bindProfileVisibilityRefresh } = usePlayerProfile();
const { startPolling, stopPolling } = useDepositNotifications();
const { unreadCount, refreshUnreadCount, resetMessagesState } = usePlayerMessages();
const { inboxEnabled, hubRoute, hubOpenLabelKey } = useInboxFeature();
const showFloatCsBtn = computed(() => !route.path.startsWith('/messages'));
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
const mainRef = ref<HTMLElement | null>(null);
const tabScrollTops = new Map<string, number>();
@@ -254,29 +92,14 @@ const balanceRefreshPaths = ['/profile', '/wallet', '/bets'];
onMounted(() => {
if (auth.user?.locale) void initFromUser(auth.user.locale);
bindProfileVisibilityRefresh();
const savedX = localStorage.getItem('float_cs_x');
const savedY = localStorage.getItem('float_cs_y');
const savedSide = localStorage.getItem('float_cs_side');
const savedDocked = localStorage.getItem('float_cs_docked');
if (savedX !== null && savedY !== null) {
btnX.value = Number(savedX);
btnY.value = Number(savedY);
}
if (savedSide === 'left' || savedSide === 'right') {
dockSide.value = savedSide;
}
if (savedDocked !== null) {
isDocked.value = savedDocked === '1';
}
});
watch(
() => auth.token,
(token) => {
// é¦é¡µæ•°æ<EFBFBD>®ï¼ˆå…¬åŠã€<EFBFBD>热门èµäºï¼‰å¯¹æ‰€æœ‰äººå…¬å¼€ï¼Œå§ç»ˆåŠ è½?
// 首页数据(公告、热门赛事)对所有人公开,始终加载
void loadPlayerHome(true);
// 个人资料仅登录用户需�
// 个人资料仅登录用户需要
if (token) {
void loadProfile(true);
startPolling();
@@ -309,22 +132,36 @@ watch(
<DesktopShell v-if="isDesktop" />
<div v-else class="layout">
<header v-if="showHeader" class="header">
<img src="/logo.png" alt="TheBet365" class="logo" />
<svg class="logo" viewBox="0 0 130 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="TheBet365">
<text x="0" y="18" font-family="'Inter','SF Pro Display',system-ui,sans-serif" font-size="11" font-weight="800" fill="#94A3B8" letter-spacing="0.14em">THE</text>
<text x="32" y="18" font-family="'Inter','SF Pro Display',system-ui,sans-serif" font-size="18" font-weight="900" fill="#FFB980" letter-spacing="0.01em">BET</text>
<text x="74" y="18" font-family="'Inter','SF Pro Display',system-ui,sans-serif" font-size="18" font-weight="900" fill="#FFDDD2" letter-spacing="0.01em">365</text>
</svg>
<div class="header-actions">
<LocaleSwitcher v-if="!auth.user" />
<RouterLink
:to="hubRoute"
class="hub-btn"
:aria-label="inboxEnabled && unreadCount ? t('messages.unread_badge', { count: unreadCount }) : t(hubOpenLabelKey)"
>
<svg class="hub-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linejoin="round"
/>
<circle cx="9" cy="11" r="1" fill="currentColor" />
<circle cx="12" cy="11" r="1" fill="currentColor" />
<circle cx="15" cy="11" r="1" fill="currentColor" />
</svg>
<span v-if="auth.user && inboxEnabled && unreadCount > 0" class="hub-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
</RouterLink>
<div class="header-divider" />
<LocaleSwitcher />
<template v-if="auth.user">
<CashBalanceChip />
<button
type="button"
class="settings-trigger-btn"
:aria-label="t('profile.settings')"
@click="showSettings = true"
>
<svg class="settings-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.1a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
<UserAvatarMenu />
</template>
<button v-else type="button" class="login-btn" @click="auth.showLoginPrompt(route.fullPath)">
{{ t('auth.login') }}
@@ -340,7 +177,7 @@ watch(
/>
</div>
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav, 'main--sub-page': isDetailPage }]">
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav, 'main--detail': isDetailPage }]">
<RouterView v-slot="{ Component, route: viewRoute }">
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
<component :is="Component" :key="viewRoute.path" />
@@ -379,35 +216,6 @@ watch(
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
<BetSlipDrawer v-model="slip.drawerOpen" />
<SettingsDrawer v-model="showSettings" />
<!-- Draggable Floating Customer Service Button -->
<div
v-if="showFloatCsBtn"
ref="floatBtnRef"
class="float-cs-btn"
:class="{ dragging: isDragging, docked: isDocked, 'dock-left': dockSide === 'left', 'dock-right': dockSide === 'right' }"
:style="btnStyle"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
@touchstart.prevent
:aria-label="inboxEnabled && unreadCount ? t('messages.unread_badge', { count: unreadCount }) : t(hubOpenLabelKey)"
>
<svg class="cs-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linejoin="round"
/>
<circle cx="9" cy="11" r="1" fill="currentColor" />
<circle cx="12" cy="11" r="1" fill="currentColor" />
<circle cx="15" cy="11" r="1" fill="currentColor" />
</svg>
</div>
</div>
</template>
@@ -419,178 +227,121 @@ watch(
min-height: 100dvh;
overflow: hidden;
background: transparent;
padding-top: calc(var(--app-top-gap) + env(safe-area-inset-top, 0px));
}
.header {
flex-shrink: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 6px var(--space-page-x);
min-height: var(--header-h);
background: var(--glass);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
height: 48px;
background: var(--bg-glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
z-index: 110;
}
.logo {
height: 36px; width: auto; display: block;
height: 22px;
width: auto;
display: block;
flex-shrink: 0;
}
.header-actions {
--header-chip-h: 36px;
--header-chip-h: 32px;
display: flex;
gap: 6px;
align-items: center;
min-width: 0;
}
.header-actions :deep(.locale-switch:not(.compact)),
.header-actions :deep(.cash-chip) {
.header-actions :deep(.cash-chip),
.header-actions :deep(.avatar-btn) {
height: var(--header-chip-h);
box-sizing: border-box;
}
.header-actions :deep(.avatar-btn) {
width: var(--header-chip-h);
}
.float-cs-btn {
position: fixed;
z-index: 135;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--dropdown-bg, #0A2540);
border: 1px solid var(--border-strong);
color: var(--text);
display: flex;
.header-divider {
width: 1px;
height: 18px;
background: var(--border);
flex-shrink: 0;
margin: 0 2px;
}
.hub-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15);
cursor: grab;
user-select: none;
-webkit-user-drag: none;
touch-action: none;
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), opacity 0.3s ease;
}
.float-cs-btn:active {
cursor: grabbing;
}
.float-cs-btn.dragging {
transition: none;
}
.float-cs-btn.docked {
opacity: 0.6;
}
@media (hover: hover) {
.float-cs-btn.docked:hover {
opacity: 1;
transform: translateX(0) !important;
}
}
.float-cs-btn.docked:active {
opacity: 1;
transform: translateX(0) !important;
}
.float-cs-btn.docked.dock-left {
transform: translateX(-24px);
}
.float-cs-btn.docked.dock-right {
transform: translateX(24px);
}
/* Expand touch target to bypass edge gesture intercept */
.float-cs-btn::after {
content: '';
position: absolute;
top: -12px;
bottom: -12px;
left: -24px;
right: -24px;
background: transparent;
gap: 0;
width: var(--header-chip-h, 32px);
height: var(--header-chip-h, 32px);
padding: 0;
border-radius: 50%;
pointer-events: auto;
border: 1px solid var(--border);
background: var(--bg-card-elevated);
color: var(--primary-light);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
flex-shrink: 0;
text-decoration: none;
}
.hub-btn:active {
background: var(--bg-hover);
border-color: var(--border-active);
}
.cs-icon {
width: 22px;
height: 22px;
pointer-events: none;
.hub-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.cs-badge {
.hub-badge {
position: absolute;
top: -2px;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: rgba(239, 68, 68, 0.95);
background: var(--danger, #EF4444);
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px var(--bg-body);
pointer-events: none;
}
.cs-badge.badge-left {
left: -2px;
right: auto;
}
.cs-badge.badge-right {
right: -2px;
left: auto;
box-shadow: 0 0 0 2px var(--bg-card-elevated);
}
.login-btn {
padding: 6px 14px;
padding: 6px 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: transparent;
color: var(--text);
border: none;
background: var(--gradient-primary);
color: #FFFFFF;
font-size: 13px;
font-weight: 500;
font-weight: 700;
cursor: pointer;
transition: background 0.18s;
transition: opacity 0.15s, transform 0.15s;
box-shadow: var(--shadow-glow);
}
.login-btn:active {
background: var(--surface-active);
opacity: 0.9;
transform: scale(0.98);
}
.settings-trigger-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--header-chip-h, 36px);
height: var(--header-chip-h, 36px);
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
flex-shrink: 0;
cursor: pointer;
transition: background 0.18s, border-color 0.18s;
padding: 0;
}
.settings-trigger-btn:active {
background: var(--surface-active);
}
.settings-icon {
width: 18px;
height: 18px;
}
.announce-strip {
flex-shrink: 0;
z-index: 105;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
background: var(--bg-body);
}
.main {
@@ -599,26 +350,23 @@ watch(
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: var(--space-section) var(--space-page-x) 0;
padding: 10px 14px 0;
}
.main.main--sub-page {
padding-top: 0;
}
.main.has-nav {
padding-bottom: 16px;
}
.main.main--detail {
padding-top: 0;
}
.bottom-nav {
display: flex;
background: var(--glass);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
background: var(--bg-glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid var(--border);
box-shadow: none;
z-index: 100;
padding-bottom: env(safe-area-inset-bottom, 0);
min-height: var(--nav-h);
}
.nav-item {
flex: 1;
@@ -626,16 +374,17 @@ watch(
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 2px 8px;
gap: 3px;
padding: 7px 2px 8px;
font-size: 10px;
color: var(--text-muted);
font-weight: 500;
letter-spacing: 0;
letter-spacing: 0.01em;
position: relative;
transition: color 0.18s;
transition: color 0.2s;
line-height: 1.2;
min-width: 0;
text-decoration: none;
}
.nav-label {
@@ -647,16 +396,24 @@ watch(
}
.nav-item.active {
color: var(--text);
font-weight: 600;
color: var(--primary-light);
font-weight: 700;
text-shadow: 0 0 8px rgba(255, 185, 128, 0.4);
}
.nav-item.active :deep(.nav-icon) {
color: var(--text);
filter: none;
color: var(--primary-light);
filter: drop-shadow(0 0 6px rgba(255, 185, 128, 0.5));
}
.nav-item.active::after {
display: none;
content: '';
position: absolute;
top: 0;
left: 18%;
right: 18%;
height: 2.5px;
background: var(--gradient-primary);
border-radius: 0 0 3px 3px;
}
</style>
</style>

View File

@@ -12,11 +12,6 @@ import {
type PlayerLocale,
} from './i18n/index.ts';
/** iOS Safari阻止双指捏合缩放配合 viewport 保持页面比例稳定 */
if (typeof window !== 'undefined') {
document.addEventListener('gesturestart', (event) => event.preventDefault());
}
async function bootstrap() {
const initialLocale = readStoredLocale();
const initialMessages = await loadLocaleMessages(initialLocale);

View File

@@ -1,241 +1,149 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
color-scheme: dark;
/* ── 海军 · 白色强调(替代红色) ── */
--primary: rgba(255, 255, 255, 0.92);
--primary-dark: rgba(255, 255, 255, 0.78);
--primary-light: rgba(255, 255, 255, 0.68);
--primary-muted: rgba(255, 255, 255, 0.52);
--on-primary: #001A33;
--secondary: #0A3558;
--tertiary: #001A33;
--neutral: rgba(255, 255, 255, 0.42);
--bg-body: #001A33;
--dropdown-bg: #0A2540;
--bg-card: rgba(255, 255, 255, 0.045);
--bg-hover: rgba(255, 255, 255, 0.06);
--bg-elevated: rgba(255, 255, 255, 0.08);
--bg-input: rgba(255, 255, 255, 0.06);
--captcha-canvas-bg: #002244;
--captcha-char: #ffffff;
/* ── 蓉色珊瑚 — Dusk Coral Theme ── */
--primary: #F4A261;
--primary-dark: #E76F51;
--primary-light: #FFB980;
--primary-glow: #FFDDD2;
--accent: #E76F51;
--accent-light: #FFDDD2;
--gradient-primary: linear-gradient(135deg, #E76F51, #F4A261);
--gradient-accent: linear-gradient(135deg, #F4A261, #FFDDD2);
--gradient-hot: linear-gradient(135deg, #E76F51, #F4A261, #FFDDD2);
--gradient-success: linear-gradient(135deg, #2C5364, #203A43);
--gradient-hero: linear-gradient(135deg, #0F2027, #203A43, #0F2027);
--secondary: #203A43;
--tertiary: #1A3038;
--neutral: #8FA3AD;
--bg-body: #0F2027;
--glass-bg: rgba(22, 45, 54, 0.82);
--glass-border: rgba(255, 255, 255, 0.1);
--glass-border-accent: rgba(244, 162, 97, 0.32);
--glass-glow: 0 0 20px rgba(0, 0, 0, 0.18);
--bg-card: var(--glass-bg);
--bg-card-elevated: rgba(28, 55, 66, 0.88);
--bg-hover: rgba(44, 83, 100, 0.55);
--bg-elevated: rgba(28, 55, 66, 0.88);
--bg-input: #162D36;
--captcha-canvas-bg: #162D36;
--captcha-char: #F4A261;
--captcha-noise-rgb: 255, 255, 255;
--surface-active: rgba(255, 255, 255, 0.1);
--surface-accent: rgba(255, 255, 255, 0.08);
--text: rgba(255, 255, 255, 0.94);
--text-muted: rgba(255, 255, 255, 0.56);
--text-dim: rgba(255, 255, 255, 0.38);
--border: rgba(255, 255, 255, 0.1);
--border-strong: rgba(255, 255, 255, 0.16);
--border-gold: rgba(255, 255, 255, 0.2);
--border-gold-soft: rgba(255, 255, 255, 0.18);
--bg-glass: rgba(15, 32, 39, 0.85);
--text: #FFFFFF;
--text-secondary: #D4E4EB;
--text-muted: #8FA3AD;
--text-dim: #5E7A86;
--border: rgba(255, 255, 255, 0.08);
--border-active: #F4A261;
--border-w: 1px;
--border-w-thick: 1px;
--danger: rgba(255, 255, 255, 0.62);
--success: #34C759;
--danger: #EF4444;
--success: #4ADE80;
--warning: #FBBF24;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 32px rgba(0, 0, 0, 0.22);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.16);
--shadow-card: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 8px 28px rgba(0, 0, 0, 0.22);
--shadow-card-sm: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 18px rgba(0, 0, 0, 0.18);
--shadow-gold: none;
--glow-gold: none;
--gradient-accent: rgba(255, 255, 255, 0.88);
--gradient-gold: var(--gradient-accent);
--gradient-gold-border: var(--gradient-accent);
--gradient-card: linear-gradient(
165deg,
rgba(255, 255, 255, 0.11) 0%,
rgba(255, 255, 255, 0.055) 24%,
rgba(10, 53, 88, 0.32) 58%,
rgba(0, 20, 40, 0.62) 100%
);
--gradient-card-soft: linear-gradient(
180deg,
rgba(255, 255, 255, 0.08) 0%,
rgba(255, 255, 255, 0.03) 40%,
rgba(0, 26, 51, 0.45) 100%
);
--gradient-card-shine: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.28) 50%,
transparent 100%
);
--glass: rgba(0, 26, 51, 0.78);
--space-page-x: 20px;
--space-toolbar-top: 8px;
--space-section: 16px;
--space-card: 16px;
--header-h: 48px;
--nav-h: 52px;
--app-top-gap: 8px;
--radius-lg: 16px;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.32);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.38);
--shadow-lg: 0 8px 28px rgba(0, 0, 0, 0.45);
--shadow-glow: 0 0 20px rgba(244, 162, 97, 0.2);
--shadow-glow-accent: 0 0 20px rgba(231, 111, 81, 0.2);
}
/** 描边按钮 */
.btn-gold-outline {
background: transparent;
border: 1px solid var(--border-strong);
color: var(--text);
font-weight: 500;
}
.btn-gold-outline:active:not(:disabled) {
background: var(--surface-active);
border-color: var(--border-strong);
/* Odds / betting accent — coral orange */
:root {
--odds-accent: #F4A261;
--odds-accent-light: #FFB980;
--odds-accent-soft: rgba(244, 162, 97, 0.1);
}
/** 选中态:中性浅底 */
.tab-gold-active {
background: var(--bg-elevated) !important;
border-color: var(--border-strong) !important;
color: var(--text) !important;
}
/* ── Global resets ── */
html, body, #app { height: 100%; min-height: 100%; }
/* 登录等关键区域 */
.ps-gold-frame {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.12) !important;
border-radius: var(--radius);
background: var(--gradient-card) !important;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: var(--shadow-card-sm);
overflow: hidden;
}
.ps-gold-frame::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
}
.ps-gold-frame::after {
display: none;
}
.ps-gold-input {
background: var(--bg-input) !important;
border: 1px solid var(--border) !important;
box-shadow: none;
color: var(--text);
}
.ps-gold-input::placeholder {
color: var(--text-dim);
}
.ps-gold-input:focus {
border-color: var(--border-strong) !important;
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.08) !important;
}
/* 主按钮 · 扁平 CTAStripe 式) */
.btn-primary {
position: relative;
padding: 14px 24px;
border: none;
border-radius: var(--radius-sm);
background: var(--primary);
color: var(--on-primary);
font-weight: 600;
font-size: 15px;
width: 100%;
letter-spacing: -0.01em;
text-transform: none;
transition: opacity 0.15s ease, transform 0.12s ease;
box-shadow: none;
}
.btn-primary:active:not(:disabled) {
transform: scale(0.985);
opacity: 0.88;
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* 筛选 Tab · iOS 分段风格 */
.filter-tabs {
display: flex;
gap: 6px;
padding: 0 0 var(--space-section);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.filter-tabs::-webkit-scrollbar { display: none; }
.filter-tab {
flex-shrink: 0;
padding: 7px 14px;
border-radius: 999px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.18s, border-color 0.18s, color 0.18s;
}
.filter-tab.active {
background: var(--surface-active);
border-color: var(--border-gold);
color: var(--text);
font-weight: 600;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
html,
body,
#app {
height: 100%;
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
height: -webkit-fill-available;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, sans-serif;
position: relative;
isolation: isolate;
min-height: 100%;
min-height: -webkit-fill-available;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-body);
background-image: none;
color: var(--text);
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: -0.01em;
}
body::before {
content: '';
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
background-image: url('./assets/images/h5bg2.webp');
background-repeat: no-repeat;
background-position: center 240px;
background-size: 84% auto;
opacity: 0.55;
filter: saturate(0.9);
}
body::after {
content: '';
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background:
radial-gradient(ellipse 120% 60% at 50% -10%, rgba(244, 162, 97, 0.08), transparent 55%),
linear-gradient(rgba(15, 32, 39, 0.72), rgba(15, 32, 39, 0.82));
}
#app {
min-height: 100%;
min-height: -webkit-fill-available;
}
a { color: inherit; text-decoration: none; }
button { cursor: pointer; border: none; font-family: inherit; }
button {
cursor: pointer;
border: none;
font-family: inherit;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(244, 162, 97, 0.3); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(244, 162, 97, 0.5); }
/* ── Input ── */
input {
font-family: inherit;
background: var(--bg-input);
border: 1px solid var(--border);
color: var(--text);
padding: 13px 16px;
padding: 12px 14px;
border-radius: var(--radius-sm);
width: 100%;
font-size: 16px;
font-weight: 400;
transition: border-color 0.18s, box-shadow 0.18s;
font-size: 15px;
font-weight: 500;
transition: border-color 0.2s, box-shadow 0.2s;
}
input::placeholder { color: var(--text-dim); }
input:focus {
outline: none;
border-color: var(--border-strong);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.08);
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(244, 162, 97, 0.15);
}
input:-webkit-autofill,
@@ -244,96 +152,168 @@ input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-text-fill-color: var(--text);
caret-color: var(--text);
box-shadow: 0 0 0 1000px #0A2540 inset;
box-shadow: 0 0 0 1000px var(--bg-input) inset;
border: 1px solid var(--border);
transition: background-color 99999s ease-out 0s;
}
.odds-btn {
background: var(--gradient-card-soft);
border: 1px solid rgba(255, 255, 255, 0.1);
/* ── Primary button (coral gradient CTA) ── */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 12px 24px;
border: none;
border-radius: var(--radius-sm);
background: var(--gradient-primary);
color: #FFFFFF;
font-weight: 700;
font-size: 15px;
letter-spacing: 0.02em;
width: 100%;
transition: opacity 0.15s, transform 0.1s, box-shadow 0.2s;
box-shadow: 0 4px 15px rgba(231, 111, 81, 0.35);
}
.btn-primary:hover { opacity: 0.9; box-shadow: 0 4px 20px rgba(244, 162, 97, 0.4); }
.btn-primary:active:not(:disabled) {
transform: scale(0.985);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Outline button ── */
.btn-gold-outline {
background: transparent;
border: 1px solid var(--primary);
color: var(--primary-light);
font-weight: 700;
}
.btn-gold-outline:active:not(:disabled) {
background: rgba(244, 162, 97, 0.08);
border-color: var(--primary-light);
}
/* ── Active tab ── */
.tab-gold-active {
background: rgba(244, 162, 97, 0.1) !important;
border-color: var(--primary) !important;
color: var(--primary-light) !important;
}
/* ── Card frame (glass) ── */
.glass-card,
.card,
.ps-gold-frame {
position: relative;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.ps-gold-frame::before,
.ps-gold-frame::after { display: none; }
/* Common list / panel surfaces */
.bet-card,
.order-card,
.list-card,
.match-card,
.option-card,
.my-bet-card,
.score-card,
.success-card,
.avatar-card,
.form-card,
.wallet-stats-panel,
.bet-stats-panel,
.stats-panel,
.confirm-modal,
.qa-item,
.qe-item,
.league-block,
.drawer,
.slip-item,
.hero,
.section,
.recharge-section,
.panel-card {
background: var(--glass-bg);
border: 1px solid var(--glass-border);
box-shadow: var(--shadow-md);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.ps-gold-input {
background: var(--bg-input) !important;
border: 1px solid var(--border) !important;
box-shadow: none;
color: var(--text);
padding: 11px 14px;
}
.ps-gold-input::placeholder { color: var(--text-dim); }
.ps-gold-input:focus {
border-color: var(--primary) !important;
box-shadow: 0 0 0 3px rgba(244, 162, 97, 0.15) !important;
}
/* ── Odds button ── */
.odds-btn {
background: var(--bg-card-elevated);
border: 1px solid var(--border);
color: var(--text);
padding: 12px 14px;
border-radius: var(--radius-sm);
text-align: center;
min-width: 78px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
transition: border-color 0.18s, background 0.18s, transform 0.12s;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s, background 0.2s;
}
.odds-btn:active { transform: scale(0.98); }
.odds-btn:active { transform: scale(0.96); }
.odds-btn.selected {
border-color: var(--border-strong);
background: var(--bg-elevated);
box-shadow: none;
border: 1px solid var(--primary);
background: rgba(244, 162, 97, 0.1);
box-shadow: 0 0 12px rgba(244, 162, 97, 0.12);
}
.odds-btn.selected::before,
.odds-btn.selected::after {
display: none;
}
.odds-btn.selected .label,
.odds-btn.selected .value {
position: relative;
z-index: 1;
}
.odds-btn.selected::after { display: none; }
.odds-btn .label {
font-size: 11px;
color: var(--text-dim);
font-weight: 500;
letter-spacing: 0;
text-transform: none;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.odds-btn .value {
font-size: 16px;
font-weight: 600;
color: var(--text);
text-shadow: none;
font-size: 18px;
font-weight: 800;
color: var(--primary);
margin-top: 2px;
letter-spacing: -0.02em;
}
.odds-btn.selected .value {
color: var(--text);
}
/* ── Card ── */
.card {
position: relative;
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
padding: var(--space-card);
margin-bottom: var(--space-section);
box-shadow: var(--shadow-card-sm);
overflow: hidden;
}
.card::before {
content: '';
position: absolute;
top: 0;
left: 8%;
right: 8%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
}
.section-title {
font-size: 15px;
font-weight: 600;
padding: 16px;
margin-bottom: 12px;
padding: 0;
border-left: none;
color: var(--text);
letter-spacing: -0.02em;
text-transform: none;
}
/* ── Section title ── */
.section-title {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
padding: 4px 0 4px 12px;
border-left: 3px solid var(--primary);
color: var(--text);
letter-spacing: 0.02em;
}
/* ── Empty / loading state ── */
.state {
display: flex;
flex-direction: column;
@@ -343,47 +323,94 @@ input:-webkit-autofill:active {
gap: 12px;
text-align: center;
color: var(--text-muted);
font-weight: 500;
font-weight: 600;
font-size: 14px;
}
/* Sub-page toolbar: full-bleed under .main horizontal padding */
.sub-toolbar {
box-sizing: border-box;
margin-left: calc(-1 * var(--space-page-x));
margin-right: calc(-1 * var(--space-page-x));
width: calc(100% + 2 * var(--space-page-x));
padding: var(--space-toolbar-top) var(--space-page-x) 8px;
background: var(--bg-body);
}
.sub-toolbar--sticky {
position: sticky;
top: 0;
z-index: 50;
}
.sub-toolbar--glass {
background: var(--bg-body);
border-bottom: 1px solid var(--border);
}
/* Top-left diagonal ribbon for open-match status */
.status-ribbon {
/* ── Auth pages (login / register / forgot) ── */
.auth-page::before {
content: '';
position: absolute;
top: 12px;
left: -28px;
width: 100px;
transform: rotate(-45deg);
background: var(--success);
color: #fff;
font-size: 10px;
font-weight: 600;
text-align: center;
padding: 4px 0;
z-index: 3;
line-height: 1.3;
letter-spacing: 0.02em;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
inset: 0;
z-index: 0;
pointer-events: none;
background: linear-gradient(
180deg,
transparent 0%,
transparent 32%,
rgba(15, 32, 39, 0.25) 58%,
rgba(15, 32, 39, 0.72) 100%
);
}
.auth-page::after {
content: '';
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: linear-gradient(165deg, rgba(244, 162, 97, 0.14) 0%, transparent 42%);
}
.auth-card {
background: var(--glass-bg) !important;
border: 1px solid var(--glass-border-accent) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-lg), 0 0 28px rgba(244, 162, 97, 0.1) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.auth-card label {
color: var(--text-secondary);
font-size: 11px;
}
.auth-card .field-input {
background: rgba(15, 32, 39, 0.55);
border-color: rgba(255, 255, 255, 0.12);
}
.auth-card .field-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(244, 162, 97, 0.18);
}
.auth-card .btn-login {
background: var(--gradient-primary);
border: none;
color: #fff;
box-shadow: 0 4px 16px rgba(231, 111, 81, 0.38);
}
.auth-card .btn-login:active:not(:disabled) {
opacity: 0.92;
transform: scale(0.985);
}
.auth-card .btn-secondary {
background: rgba(15, 32, 39, 0.5);
border-color: var(--border);
color: var(--primary-light);
}
.auth-page .btn-skip-light {
border-color: var(--border);
background: rgba(22, 45, 54, 0.45);
backdrop-filter: blur(8px);
color: var(--text-secondary);
}
.auth-page .btn-skip-light:active {
border-color: var(--primary);
color: var(--primary-light);
}
@media (min-width: 1024px) {
body::before {
background-image: var(--gradient-hero) !important;
background-size: cover;
background-position: center;
opacity: 0.85;
}
}

View File

@@ -47,7 +47,7 @@
.desktop-shell input:focus {
border-color: var(--border-gold) !important;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2) !important;
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.2) !important;
}
/* Button hover overrides */
@@ -55,7 +55,7 @@
transition: background-color 0.2s, border-color 0.2s;
}
.desktop-shell .btn-gold-outline:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
border-color: var(--border-gold);
}
.desktop-shell .btn-gold-outline:active {
@@ -79,7 +79,7 @@
}
.desktop-skeleton {
background: linear-gradient(90deg, #0A2540 25%, rgba(255, 255, 255, 0.06) 37%, #0A2540 63%);
background: linear-gradient(90deg, var(--tertiary) 25%, var(--secondary) 37%, var(--tertiary) 63%);
background-size: 400% 100%;
animation: desktop-skeleton-glow 1.4s ease infinite;
}
@@ -87,10 +87,10 @@
@keyframes bet-locate-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0);
box-shadow: 0 0 0 0 rgba(244, 162, 97, 0);
}
50% {
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.45);
box-shadow: 0 0 0 3px rgba(244, 162, 97, 0.45);
}
}

View File

@@ -4,18 +4,19 @@
inset: 0;
display: flex;
flex-direction: column;
background-color: #001A33;
/* background-image removed for theme-4 */
background-color: var(--desktop-bg);
background-image: var(--gradient-hero);
background-size: cover;
background-position: center;
background-attachment: fixed;
overflow: hidden;
}
.desktop-header-wrap {
flex-shrink: 0;
height: var(--desktop-header-h);
background: #001A33;
background: var(--bg-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border);
z-index: 120;
display: flex;
@@ -28,7 +29,7 @@
flex-shrink: 0;
z-index: 110;
border-bottom: 1px solid var(--border);
background: #001A33;
background: rgba(15, 32, 39, 0.9);
}
.desktop-shell.betslip-hidden {
@@ -69,7 +70,8 @@
width: 100%;
padding: 0 16px;
border-bottom: 1px solid var(--border);
background: rgba(0, 26, 51, 0.9);
background: var(--bg-card);
backdrop-filter: blur(10px);
}
.desktop-layout-betting {
@@ -82,7 +84,8 @@
.desktop-betting-left {
border-right: 1px solid var(--border);
background: rgba(10, 37, 64, 0.8);
background: rgba(15, 32, 39, 0.6);
backdrop-filter: blur(10px);
overflow-y: auto;
}
@@ -175,7 +178,7 @@
.desktop-hub-right {
overflow-y: auto;
padding: 20px;
background: rgba(255, 255, 255, 0.03);
background: rgba(15, 32, 39, 0.3);
}
/* Auth Layout: Centered container */
@@ -193,8 +196,10 @@
width: 100%;
max-width: 440px;
padding: 30px;
background: rgba(10, 37, 64, 0.95);
background: var(--bg-card);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-gold-soft);
border-radius: 8px;
border-radius: var(--radius);
box-shadow: var(--shadow);
}

View File

@@ -19,8 +19,11 @@
gap: 20px 32px;
flex-wrap: wrap;
flex-shrink: 0;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
padding: 14px 18px;
background: var(--desktop-surface-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.desktop-records-header-main {
@@ -43,9 +46,9 @@
align-items: center;
justify-content: center;
padding: 8px 18px;
border-radius: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-gold-soft);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
@@ -55,7 +58,7 @@
}
.desktop-records-action-btn:hover {
background: rgba(255, 255, 255, 0.14);
background: rgba(244, 162, 97, 0.14);
border-color: var(--border-gold);
}
@@ -98,7 +101,7 @@
.desktop-records-filter-chip {
padding: 7px 16px;
border-radius: 6px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
@@ -111,13 +114,13 @@
.desktop-records-filter-chip:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.25);
border-color: rgba(244, 162, 97, 0.25);
}
.desktop-records-filter-chip.active {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.35);
background: rgba(244, 162, 97, 0.1);
border-color: rgba(244, 162, 97, 0.35);
}
.desktop-wallet-subnav {
@@ -130,7 +133,7 @@
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 6px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
@@ -146,8 +149,8 @@
.desktop-wallet-subnav-link.active {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.28);
background: rgba(244, 162, 97, 0.1);
border-color: rgba(244, 162, 97, 0.28);
}
.desktop-records-panel {
@@ -155,9 +158,10 @@
flex-direction: column;
flex: 1;
min-height: 0;
background: rgba(10, 37, 64, 0.94);
background: var(--desktop-surface-solid);
border: 1px solid var(--border);
border-radius: 10px;
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow: hidden;
}
@@ -182,7 +186,7 @@
font-size: 11px;
font-weight: 800;
color: var(--text-muted);
background: #0A2540;
background: var(--desktop-surface-header);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
@@ -240,8 +244,8 @@
.desktop-records-pagination {
flex-shrink: 0;
padding: 12px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(0, 26, 51, 0.5);
border-top: 1px solid var(--border);
background: var(--desktop-surface-header);
}
.desktop-records-loading,
@@ -302,7 +306,7 @@
.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(255, 255, 255, 0.12); color: var(--primary-light); }
.desktop-records-status.status-pending { background: rgba(244, 162, 97, 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; }

View File

@@ -1,28 +1,37 @@
/* Teleport 弹窗挂在 body 下,须用 :root 暴露实心色,不能只在 .desktop-shell 内定义 */
:root {
--desktop-surface-solid: #1a323c;
--desktop-surface-elevated: #1c3742;
--desktop-surface-header: #162d36;
--desktop-border: rgba(255, 255, 255, 0.08);
}
.desktop-shell {
/* Color Palette overrides */
--primary: rgba(255, 255, 255, 0.92);
--primary-dark: rgba(255, 255, 255, 0.78);
--primary-light: rgba(255, 255, 255, 0.68);
--secondary: #0A3558;
--tertiary: #001A33;
--neutral: #8E8E93;
--bg-body: #001A33;
--bg-card: rgba(255, 255, 255, 0.045);
--bg-hover: rgba(255, 255, 255, 0.06);
--bg-elevated: rgba(255, 255, 255, 0.08);
--text: rgba(255, 255, 255, 0.94);
--text-muted: rgba(255, 255, 255, 0.56);
--border: rgba(255, 255, 255, 0.1);
--border-gold: rgba(255, 255, 255, 0.2);
--border-gold-soft: rgba(255, 255, 255, 0.18);
/* Color Palette overrides (theme-3 Dusk Coral style) */
--primary: #F4A261;
--primary-dark: #E76F51;
--primary-light: #FFB980;
--secondary: #203A43;
--tertiary: #1A3038;
--neutral: #8FA3AD;
--bg-body: #0F2027;
--bg-card: rgba(22, 45, 54, 0.82);
--bg-hover: rgba(44, 83, 100, 0.55);
--bg-elevated: rgba(28, 55, 66, 0.88);
--text: #FFFFFF;
--text-muted: #8FA3AD;
--border: rgba(255, 255, 255, 0.08);
--border-gold: #F4A261;
--border-gold-soft: rgba(244, 162, 97, 0.25);
--border-w: 1px;
--border-w-thick: 1px;
--danger: rgba(255, 255, 255, 0.62);
--danger: #EF4444;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.2), 0 12px 32px rgba(0, 0, 0, 0.22);
--shadow-gold: none;
--glow-gold: none;
--shadow: 0 4px 16px rgba(0, 0, 0, 0.38);
--shadow-gold: 0 0 20px rgba(244, 162, 97, 0.2);
--glow-gold: 0 0 6px rgba(244, 162, 97, 0.15);
--odds-accent: #F4A261;
/* PC Dimensioning */
--desktop-header-h: 56px;
@@ -32,13 +41,17 @@
--desktop-right-betslip-collapsed-w: 40px;
/* Desktop specific added vars */
--desktop-bg: #001A33;
--desktop-border: rgba(255, 255, 255, 0.1);
--desktop-sidebar-bg: rgba(10, 37, 64, 0.95);
--desktop-sidebar-panel-bg: linear-gradient(rgba(0, 26, 51, 0.88), rgba(0, 26, 51, 0.88));
--desktop-bg: #0F2027;
--desktop-border: rgba(255, 255, 255, 0.08);
--desktop-sidebar-bg: rgba(22, 45, 54, 0.82);
--desktop-sidebar-panel-bg: linear-gradient(135deg, rgba(22, 45, 54, 0.95), rgba(15, 32, 39, 0.98));
--desktop-surface-solid: #1a323c;
--desktop-surface-elevated: #1c3742;
--desktop-surface-header: #162d36;
--gradient-hero: linear-gradient(135deg, #0F2027, #203A43, #0F2027);
/* Reset typography inside desktop */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
line-height: 1.4;
color: var(--text);

View File

@@ -0,0 +1,63 @@
import api from '../api';
export type DepositAuditLog = {
id: string;
action: string;
actorType: string;
statusBefore: string | null;
statusAfter: string;
amount: string | null;
approvedAmount: string | null;
remark: string | null;
createdAt: string;
};
export type PlayerDepositOrder = {
id: string;
orderNo: string;
paymentMethodId?: string;
methodType: string;
amount: string;
status: string;
approvedAmount: string | null;
rejectReason: string | null;
remark: string | null;
createdAt: string;
reviewedAt: string | null;
paymentMethodName: string | null;
bankName?: string | null;
auditLogs?: DepositAuditLog[];
};
export async function fetchDepositOrderAuditLogs(orderId: string): Promise<DepositAuditLog[]> {
try {
const { data } = await api.get(`/player/deposit-orders/${orderId}/audit-logs`);
const items = data.data?.items ?? data.data ?? [];
return Array.isArray(items) ? (items as DepositAuditLog[]) : [];
} catch {
return [];
}
}
export async function enrichDepositOrder(order: PlayerDepositOrder): Promise<PlayerDepositOrder> {
if (order.auditLogs?.length) return order;
const auditLogs = await fetchDepositOrderAuditLogs(order.id);
return auditLogs.length ? { ...order, auditLogs } : order;
}
export async function findDepositOrderById(orderId: string): Promise<PlayerDepositOrder | null> {
let page = 1;
const pageSize = 20;
while (true) {
const { data } = await api.get('/player/deposit-orders', { params: { page, pageSize } });
const result = data.data ?? { items: [], total: 0, pageSize };
const items = (result.items ?? []) as PlayerDepositOrder[];
const found = items.find((order) => order.id === orderId);
if (found) return enrichDepositOrder(found);
const total = result.total ?? 0;
if (!items.length || page * pageSize >= total) return null;
page += 1;
}
}

View File

@@ -1,18 +0,0 @@
export type WalletTransactionStats = {
netAmount?: string;
byType?: Array<{ transactionType?: string; totalAmount?: string }>;
};
const CASHBACK_TYPES = new Set(['CASHBACK', 'CASHBACK_DEPOSIT']);
/** Sum wallet credits from cashback transaction types (matches WalletDetailView fallback). */
export function sumCashbackFromStats(stats: WalletTransactionStats | null | undefined): number {
let sum = 0;
for (const group of stats?.byType ?? []) {
const type = group.transactionType?.toUpperCase() ?? '';
if (CASHBACK_TYPES.has(type)) {
sum += Math.abs(parseFloat(group.totalAmount ?? '0') || 0);
}
}
return sum;
}

View File

@@ -1,9 +1,23 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useViewport } from '../composables/useViewport';
import MobileBetDetailView from './MobileBetDetailView.vue';
import DesktopBetDetailView from './desktop/DesktopBetDetailView.vue';
const route = useRoute();
const router = useRouter();
const { isDesktop } = useViewport();
onMounted(() => {
if (!isDesktop.value) return;
const betNo = route.params.betNo;
if (betNo) {
void router.replace({ path: '/bets', query: { betNo: String(betNo) } });
}
});
</script>
<template>
<DesktopBetDetailView v-if="isDesktop" />
<MobileBetDetailView v-else />

View File

@@ -7,7 +7,7 @@ import { useSmsCode } from '../composables/useSmsCode';
import api from '../api';
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
import loginBg from '../assets/images/h5bg.webp';
import loginBg from '../assets/images/h5bg2.webp';
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
@@ -100,109 +100,84 @@ function goLogin() {
</script>
<template>
<div
class="login-page"
:class="{ 'login-page--desktop': props.desktop }"
:style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }"
>
<div class="login-page auth-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>
<div class="login-stack">
<form
@submit.prevent="submit"
class="login-form"
:class="{ 'ps-gold-frame': !props.desktop }"
>
<h2 class="form-title">{{ t('auth.forgot_password_title') }}</h2>
<form @submit.prevent="submit" class="login-form ps-gold-frame auth-card">
<h2 class="form-title">{{ t('auth.forgot_password_title') }}</h2>
<div class="field">
<label>{{ t('auth.phone') }}</label>
<div class="inline-row">
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
type="tel"
required
autocomplete="tel-national"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
</div>
<div class="field">
<label>{{ t('auth.sms_code') }}</label>
<div class="inline-row">
<input
v-model="smsCode"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
:placeholder="t('auth.sms_code_placeholder')"
/>
<button
type="button"
class="btn-secondary"
:disabled="sending || countdown > 0 || !phone.trim()"
@click="sendCode"
>
{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}
</button>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<div class="field">
<label>{{ t('auth.phone') }}</label>
<div class="inline-row">
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="password"
v-model="phone"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
type="password"
type="tel"
required
autocomplete="new-password"
minlength="8"
:placeholder="t('auth.password_placeholder')"
autocomplete="tel-national"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('auth.confirm_password') }}</label>
<input
v-model="confirmPassword"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
type="password"
required
autocomplete="new-password"
minlength="8"
/>
</div>
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
<button
type="submit"
class="btn-login btn-gold-outline"
:disabled="loading"
>
{{ t('auth.reset_password_btn') }}
</button>
<button v-if="props.desktop" type="button" class="btn-skip btn-skip--desktop" @click="goLogin">
{{ t('auth.back_to_login') }}
</button>
</form>
<div v-if="!props.desktop" class="login-actions">
<button type="button" class="btn-skip" @click="goLogin">
{{ t('auth.back_to_login') }}
</button>
</div>
</div>
<div class="field">
<label>{{ t('auth.sms_code') }}</label>
<div class="inline-row">
<input
v-model="smsCode"
class="field-input"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
:placeholder="t('auth.sms_code_placeholder')"
/>
<button
type="button"
class="btn-secondary"
:disabled="sending || countdown > 0 || !phone.trim()"
@click="sendCode"
>
{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}
</button>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<input
v-model="password"
class="field-input"
type="password"
required
autocomplete="new-password"
minlength="8"
:placeholder="t('auth.password_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('auth.confirm_password') }}</label>
<input
v-model="confirmPassword"
class="field-input"
type="password"
required
autocomplete="new-password"
minlength="8"
/>
</div>
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
<button type="submit" class="btn-login btn-primary" :disabled="loading">
{{ t('auth.reset_password_btn') }}
</button>
<button type="button" class="btn-skip" @click="goLogin">
{{ t('auth.back_to_login') }}
</button>
</form>
</div>
</template>
@@ -211,7 +186,7 @@ function goLogin() {
position: absolute;
top: max(12px, env(safe-area-inset-top));
right: 16px;
z-index: 10;
z-index: 2;
}
.login-page {
@@ -230,90 +205,24 @@ function goLogin() {
background-repeat: no-repeat;
}
.login-page::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0, 34, 68, 0.35) 0%,
rgba(0, 34, 68, 0.78) 52%,
rgba(0, 34, 68, 0.94) 100%
);
pointer-events: none;
z-index: 0;
}
/* ===== 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-page--desktop::before {
display: none;
}
.login-stack {
.login-form {
position: relative;
z-index: 1;
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
gap: 8px;
}
.login-page--desktop .login-stack {
max-width: none;
}
.login-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.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-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0 14px 4px;
padding: 22px 20px;
}
.form-title {
margin: 0 0 2px;
font-size: 16px;
margin: 0 0 4px;
font-size: 17px;
font-weight: 800;
color: #fff;
text-align: center;
}
.login-page--desktop .form-title {
margin: 0 0 8px;
font-size: 18px;
text-align: left;
color: var(--primary-light);
letter-spacing: 0.04em;
text-align: center;
letter-spacing: 0.02em;
}
.field {
@@ -322,59 +231,26 @@ function goLogin() {
gap: 4px;
}
.login-page--desktop .field {
gap: 8px;
}
label {
font-size: 10px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
}
.login-page--desktop label {
font-size: 13px;
letter-spacing: 0.03em;
}
.field-input {
width: 100%;
height: 36px;
padding: 0 10px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text);
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field-input::placeholder {
color: var(--text-dim);
}
.field-input:focus {
outline: none;
border-color: var(--border-gold-soft);
}
/* 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-strong);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.08);
}
.login-page--desktop .field-input:hover:not(:focus) {
border-color: var(--border-gold-soft);
}
.inline-row {
@@ -394,31 +270,12 @@ label {
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
background: var(--bg-elevated);
color: var(--primary);
font-size: 11px;
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: var(--border-gold-soft);
background: var(--bg-hover);
}
.btn-secondary:not(:disabled):active {
border-color: var(--border-gold);
background: var(--surface-accent);
color: var(--text);
}
.btn-secondary:disabled {
@@ -432,28 +289,6 @@ 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: var(--surface-accent);
border-color: var(--border-strong);
}
.login-page--desktop .btn-login:active:not(:disabled) {
transform: scale(0.98);
}
.btn-login:disabled {
@@ -461,40 +296,19 @@ label {
cursor: not-allowed;
}
.login-page--desktop .btn-login:disabled {
opacity: 0.55;
}
.btn-skip {
margin-top: 2px;
padding: 8px 14px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.55);
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s ease;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
}
.btn-skip--desktop {
margin-top: 6px;
padding: 12px 16px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-input);
font-size: 14px;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}
.btn-skip--desktop:hover:not(:disabled) {
border-color: var(--border-gold-soft);
color: var(--text);
background: var(--bg-hover);
color: var(--primary-light);
}
.error {
@@ -505,22 +319,92 @@ label {
line-height: 1.3;
}
/* ===== 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-page--desktop .login-form {
max-width: none;
width: 100%;
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.login-page--desktop .field {
gap: 8px;
}
.login-page--desktop label {
font-size: 13px;
letter-spacing: 0.03em;
}
.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(--primary);
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.18);
}
.login-page--desktop .field-input:hover:not(:focus) {
border-color: rgba(255, 255, 255, 0.18);
}
.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);
}
.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(244, 162, 97, 0.1);
border-color: var(--primary);
}
.login-page--desktop .btn-login:active:not(:disabled) {
transform: scale(0.98);
}
.login-page--desktop .btn-login:disabled {
opacity: 0.55;
}
.login-page--desktop .btn-skip {
margin-top: 6px;
padding: 12px 16px;
border-radius: 4px;
border: 1px solid var(--border);
background: rgba(15, 32, 39, 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);
}
.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);
}
/* 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;
}
border-left: 2px solid var(--danger, #FF453A);
}
</style>

View File

@@ -8,8 +8,7 @@ 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';
import loginBg from '../assets/images/h5bg2.webp';
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
@@ -88,125 +87,64 @@ function goRegister() {
</script>
<template>
<div
class="login-page"
:class="{ 'login-page--desktop': props.desktop }"
:style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }"
>
<div class="login-page auth-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>
<div class="login-stack">
<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 class="auth-tabs" :class="{ 'auth-tabs--desktop': props.desktop }">
<button
type="button"
class="auth-tab"
:class="{ active: loginMode === 'account' }"
@click="switchLoginMode('account')"
>
{{ t('auth.login_by_account') }}
</button>
<button
type="button"
class="auth-tab"
:class="{ active: loginMode === 'phone' }"
@click="switchLoginMode('phone')"
>
{{ t('auth.login_by_phone') }}
</button>
</div>
<div v-if="loginMode === 'account'" class="field">
<label>{{ t('auth.username') }}</label>
<input
v-model="account"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
required
autocomplete="username"
maxlength="32"
:disabled="loading"
:placeholder="t('auth.username_placeholder')"
/>
</div>
<div v-else class="field">
<label>{{ t('auth.phone') }}</label>
<div class="inline-row">
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
type="tel"
required
autocomplete="tel-national"
:disabled="loading"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<input
v-model="password"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
type="password"
required
autocomplete="current-password"
:disabled="loading"
/>
</div>
<button type="button" class="btn-forgot" :disabled="loading" @click="goForgotPassword">
{{ t('auth.forgot_password') }}
</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"
: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 v-if="props.desktop" type="button" class="btn-skip btn-skip--desktop" :disabled="loading" @click="goRegister">
{{ t('auth.go_register') }}
</button>
</form>
<div v-if="!props.desktop" class="login-actions">
<button type="button" class="btn-skip" @click="goRegister">
{{ t('auth.go_register') }}
</button>
<button type="button" class="btn-skip-light" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
<form @submit.prevent="submit" class="login-form ps-gold-frame auth-card">
<div v-if="loginMode === 'account'" class="field">
<label>{{ t('auth.username') }}</label>
<input
v-model="account"
class="field-input"
required
autocomplete="username"
maxlength="32"
:placeholder="t('auth.username_placeholder')"
/>
</div>
</div>
<div v-else class="field">
<label>{{ t('auth.phone') }}</label>
<div class="inline-row">
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
type="tel"
required
autocomplete="tel-national"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<input v-model="password" class="field-input" type="password" required autocomplete="current-password" />
</div>
<button type="button" class="btn-forgot" @click="goForgotPassword">
{{ t('auth.forgot_password') }}
</button>
<button type="button" class="btn-mode-switch" @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-primary" :disabled="loading">
{{ t('auth.login') }}
</button>
<button type="button" class="btn-skip" @click="goRegister">
{{ t('auth.go_register') }}
</button>
</form>
<button v-if="!props.desktop" type="button" class="btn-skip-light" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
</div>
</template>
@@ -215,7 +153,7 @@ function goRegister() {
position: absolute;
top: max(12px, env(safe-area-inset-top));
right: 16px;
z-index: 10;
z-index: 2;
}
.login-page {
@@ -227,169 +165,22 @@ function goRegister() {
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 34vh 20px max(20px, env(safe-area-inset-bottom));
padding: 34vh 20px calc(15vh + max(28px, env(safe-area-inset-bottom)));
background-color: var(--bg-body);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
}
.login-page::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0, 34, 68, 0.35) 0%,
rgba(0, 34, 68, 0.78) 52%,
rgba(0, 34, 68, 0.94) 100%
);
pointer-events: none;
z-index: 0;
}
/* ===== 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-page--desktop::before {
display: none;
}
.login-stack {
.login-form {
position: relative;
z-index: 1;
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
gap: 8px;
}
.login-page--desktop .login-stack {
max-width: none;
}
.login-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
transition: border-color 0.25s ease, box-shadow 0.25s ease;
}
.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;
}
.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(0, 26, 51, 0.82);
backdrop-filter: blur(2px);
pointer-events: all;
}
.form-busy-text {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--primary-light);
letter-spacing: 0.04em;
}
.btn-inline-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.login-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0 14px 4px;
}
.form-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 800;
color: var(--primary-light);
text-align: left;
letter-spacing: 0.04em;
}
.auth-tabs {
display: flex;
gap: 6px;
margin-bottom: 4px;
padding: 4px;
border-radius: var(--radius-sm);
background: var(--bg-body);
border: 1px solid var(--border);
}
.auth-tabs--desktop {
margin-bottom: 6px;
background: var(--bg-input);
}
.auth-tab {
flex: 1;
min-height: 34px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.auth-tab.active {
border-color: var(--border-gold);
color: var(--text);
background: var(--surface-accent);
}
.login-page--desktop .auth-tab {
min-height: 40px;
font-size: 13px;
}
.login-page--desktop .auth-tab.active {
border-color: var(--border-strong);
padding: 22px 20px;
}
.field {
@@ -398,47 +189,20 @@ function goRegister() {
gap: 4px;
}
.login-page--desktop .field {
gap: 8px;
}
.field-input {
width: 100%;
height: 36px;
padding: 0 10px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text);
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field-input::placeholder {
color: var(--text-dim);
}
.field-input:focus {
outline: none;
border-color: var(--border-gold-soft);
}
/* 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-strong);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.08);
}
.login-page--desktop .field-input:hover:not(:focus) {
border-color: var(--border-gold-soft);
}
.inline-row {
@@ -454,14 +218,20 @@ function goRegister() {
label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
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;
padding: 0;
border: none;
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn-forgot {
@@ -474,20 +244,14 @@ label {
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: color 0.2s ease;
}
.btn-forgot:active {
color: var(--text);
color: var(--primary);
}
.login-page--desktop .btn-forgot:hover {
color: var(--text);
}
.btn-forgot:disabled {
opacity: 0.45;
cursor: not-allowed;
.btn-mode-switch:active {
color: var(--primary);
}
.btn-login {
@@ -497,28 +261,6 @@ 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: var(--surface-accent);
border-color: var(--border-strong);
}
.login-page--desktop .btn-login:active:not(:disabled) {
transform: scale(0.98);
}
.btn-login:disabled {
@@ -526,80 +268,46 @@ label {
cursor: not-allowed;
}
.login-page--desktop .btn-login:disabled {
opacity: 0.55;
}
.btn-login.is-loading:disabled {
opacity: 1;
}
.btn-skip {
padding: 6px 14px;
margin-top: 2px;
padding: 8px 14px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.55);
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: color 0.2s ease;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
}
.btn-skip--desktop {
margin-top: 6px;
padding: 12px 16px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-input);
font-size: 14px;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}
.btn-skip--desktop:hover:not(:disabled) {
border-color: var(--border-gold-soft);
color: var(--text);
background: var(--bg-hover);
}
.btn-skip:disabled {
opacity: 0.45;
cursor: not-allowed;
color: var(--primary-light);
}
.btn-skip-light {
padding: 6px 16px;
border: none;
position: absolute;
bottom: calc(20px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
z-index: 1;
padding: 8px 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: rgba(255, 255, 255, 0.45);
color: var(--text-muted);
font-size: 13px;
font-weight: 300;
font-weight: 400;
cursor: pointer;
white-space: nowrap;
text-align: center;
}
.btn-skip-light:active {
color: rgba(255, 255, 255, 0.65);
color: var(--primary);
}
.error {
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);
}
.success {
@@ -608,30 +316,97 @@ label {
font-size: 12px;
font-weight: 600;
line-height: 1.4;
animation: login-fade-in 0.25s ease;
}
/* ===== 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-page--desktop .login-form {
max-width: none;
width: 100%;
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.login-page--desktop .field {
gap: 8px;
}
.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(--primary);
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.18);
}
.login-page--desktop .field-input:hover:not(:focus) {
border-color: rgba(255, 255, 255, 0.18);
}
.login-page--desktop label {
font-size: 13px;
letter-spacing: 0.03em;
}
.login-page--desktop .btn-mode-switch:hover {
color: var(--primary-light);
}
.login-page--desktop .btn-forgot:hover {
color: rgba(255, 255, 255, 0.75);
}
.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(244, 162, 97, 0.1);
border-color: var(--primary);
}
.login-page--desktop .btn-login:active:not(:disabled) {
transform: scale(0.98);
}
.login-page--desktop .btn-login:disabled {
opacity: 0.55;
}
.login-page--desktop .btn-skip {
margin-top: 6px;
padding: 12px 16px;
border-radius: 4px;
border: 1px solid var(--border);
background: rgba(15, 32, 39, 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);
}
.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);
}
.login-page--desktop .success {
font-size: 12px;
padding: 6px 10px;
border-radius: 4px;
background: rgba(52, 199, 89, 0.08);
background: rgba(110, 231, 160, 0.08);
border-left: 2px solid var(--success);
}
@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>

View File

@@ -10,6 +10,8 @@ import { sanitizeAnnouncementHtml, stripHtml } from '../utils/html';
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const { embedded = false } = defineProps<{ embedded?: boolean }>();
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
@@ -29,7 +31,13 @@ const item = computed<PlayerContentItem | null>(() => {
const isBanner = computed(() => item.value?.contentType === 'BANNER');
function goBack() {
router.back();
if (embedded) return;
const prev = (router.options.history.state as { back?: string } | null)?.back;
if (prev && prev !== route.fullPath) {
router.back();
return;
}
void router.push('/announcements');
}
function goList() {
@@ -105,19 +113,23 @@ watch(announcementId, () => {
</script>
<template>
<div class="announce-detail">
<header class="page-header">
<div class="announce-detail" :class="{ 'is-embedded': embedded }">
<header v-if="!embedded" class="page-header">
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack"></button>
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<header v-else class="page-header page-header--hub">
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<div v-if="loading && !item" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="!item" class="empty">
<p>{{ t('announcements.not_found') }}</p>
<button type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
<button v-if="!embedded" type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
</div>
<article v-else class="detail-article">
@@ -176,7 +188,7 @@ watch(announcementId, () => {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--text);
color: var(--primary);
font-size: 22px;
line-height: 1;
}
@@ -185,7 +197,7 @@ watch(announcementId, () => {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
color: var(--text);
}
.state,
@@ -200,10 +212,10 @@ watch(announcementId, () => {
.link-btn {
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-sm);
padding: 8px 14px;
background: var(--surface-active);
color: var(--text);
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
font-size: 13px;
font-weight: 600;
}
@@ -243,14 +255,14 @@ watch(announcementId, () => {
font-size: 20px;
font-weight: 700;
line-height: 1.45;
color: #fff;
color: var(--text);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #d0d0d0;
color: var(--text-secondary);
}
.detail-body :deep(p) {
@@ -266,7 +278,7 @@ watch(announcementId, () => {
height: auto;
display: block;
margin: 14px 0;
border-radius: 8px;
border-radius: var(--radius-sm);
}
.detail-body :deep(ul),
@@ -304,9 +316,9 @@ watch(announcementId, () => {
width: 100%;
padding: 11px 14px;
border: none;
border-radius: 8px;
background: var(--primary);
color: var(--on-primary);
border-radius: var(--radius-sm);
background: var(--gradient-primary);
color: #FFFFFF;
font-size: 14px;
font-weight: 700;
cursor: pointer;
@@ -315,6 +327,30 @@ watch(announcementId, () => {
.link-action--outline {
background: transparent;
border: 1px solid var(--border);
color: var(--primary);
}
.announce-detail.is-embedded {
padding: 0 4px 24px;
}
.is-embedded .page-header--hub {
padding: 0 0 16px;
border-bottom: 1px solid var(--border);
}
.is-embedded .page-header--hub h1 {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--text);
}
.is-embedded .detail-title {
font-size: 22px;
}
.is-embedded .detail-body-wrap {
padding-top: 12px;
}
</style>

View File

@@ -1,18 +1,33 @@
<script setup lang="ts">
import { computed, onActivated } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePlayerHome } from '../composables/usePlayerHome';
import { stripHtml } from '../utils/html';
const { embedded = false } = defineProps<{ embedded?: boolean }>();
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { announcementItems, loading, load } = usePlayerHome();
const items = computed(() => announcementItems.value);
function isActive(id: string) {
return embedded && String(route.params.id ?? '') === id;
}
function onBack() {
if (embedded) {
void router.push('/');
return;
}
goBack();
}
function goBack() {
router.back();
}
@@ -45,9 +60,16 @@ onActivated(() => {
</script>
<template>
<div class="announce-page">
<div class="announce-page" :class="{ 'is-embedded': embedded }">
<header class="page-header">
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack"></button>
<button
type="button"
class="back-btn"
:aria-label="t('announcements.back')"
@click="onBack"
>
</button>
<h1>{{ t('announcements.title') }}</h1>
</header>
@@ -65,6 +87,7 @@ onActivated(() => {
:key="item.id"
type="button"
class="list-row"
:class="{ 'is-active': isActive(item.id) }"
@click="openDetail(item.id)"
>
<span class="row-main">
@@ -99,16 +122,17 @@ onActivated(() => {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--text);
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
color: var(--text);
}
.state,
@@ -175,4 +199,101 @@ onActivated(() => {
color: var(--text-muted);
line-height: 1;
}
/* Desktop hub sidebar */
.announce-page.is-embedded {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 16px 16px 0;
box-sizing: border-box;
}
.is-embedded .page-header {
flex-shrink: 0;
padding: 0 4px 12px;
margin-bottom: 4px;
}
.is-embedded .page-header h1 {
font-size: 16px;
font-weight: 800;
}
.is-embedded .back-btn {
width: 32px;
height: 32px;
font-size: 20px;
border-radius: 8px;
}
.is-embedded .list {
flex: 1;
min-height: 0;
overflow-y: auto;
margin: 0 -4px;
padding: 0 4px 16px;
}
.is-embedded .list-row {
align-items: flex-start;
gap: 10px;
padding: 12px 12px;
margin-bottom: 4px;
border: 1px solid transparent;
border-bottom: none;
border-radius: 8px;
transition: background 0.15s, border-color 0.15s;
}
.is-embedded .list-row:hover {
background: var(--bg-hover);
}
.is-embedded .list-row.is-active {
background: rgba(244, 162, 97, 0.1);
border-color: var(--border-active);
}
.is-embedded .list-row.is-active .title {
color: var(--primary);
}
.is-embedded .list-row.is-active .chevron {
color: var(--primary);
}
.is-embedded .title {
font-size: 13px;
font-weight: 700;
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: normal;
}
.is-embedded .preview {
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.is-embedded .date {
margin-top: 6px;
font-size: 11px;
}
.is-embedded .chevron {
margin-top: 2px;
font-size: 16px;
align-self: center;
}
</style>

View File

@@ -142,9 +142,9 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
</div>
<!-- back bar -->
<header class="top-bar sub-toolbar">
<div class="top-bar">
<button class="back-btn" @click="router.back()"> {{ t('history.back') }}</button>
</header>
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
@@ -275,7 +275,7 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.back-btn {
background: none;
border: none;
color: var(--primary-light);
color: var(--primary);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
@@ -293,9 +293,9 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
font-weight: 600;
}
/* ─ hero ── */
/* ─ hero ── */
.hero {
border-radius: var(--radius);
border-radius: 12px;
padding: 20px 20px 18px;
display: flex;
flex-direction: column;
@@ -305,44 +305,32 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
text-align: center;
}
.hero.won {
background: linear-gradient(135deg, rgba(46, 204, 113, 0.18), rgba(46, 204, 113, 0.06));
border: 1px solid rgba(46, 204, 113, 0.35);
}
.hero.lost {
background: linear-gradient(135deg, var(--surface-accent), rgba(255, 255, 255, 0.06));
border: 1px solid rgba(255, 255, 255, 0.35);
}
.hero.pending {
background: var(--gradient-card);
border: 1px solid var(--border-gold-soft);
}
.hero.push {
background: var(--bg-card);
border: 1px solid var(--border);
}
.hero.won { background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.25); }
.hero.lost { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.25); }
.hero.pending { background: rgba(255, 179, 0, 0.1); border: 1px solid rgba(255, 179, 0, 0.25); }
.hero.push { background: var(--bg-card); border: 1px solid var(--border); }
.hero-status {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: none;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.85;
}
.hero.won .hero-status { color: var(--success); }
.hero.lost .hero-status { color: var(--primary); }
.hero.pending .hero-status { color: var(--primary-light); }
.hero.lost .hero-status { color: var(--danger); }
.hero.pending .hero-status { color: var(--warning); }
.hero.push .hero-status { color: var(--text-muted); }
.hero-return {
font-size: 32px;
font-weight: 700;
font-size: 36px;
font-weight: 900;
letter-spacing: -0.01em;
line-height: 1.1;
}
.hero.won .hero-return { color: var(--success); text-shadow: none; }
.hero.lost .hero-return { color: var(--primary); }
.hero.pending .hero-return { color: var(--primary-light); }
.hero.won .hero-return { color: var(--success); }
.hero.lost .hero-return { color: var(--danger); }
.hero.pending .hero-return { color: var(--warning); }
.hero.push .hero-return { color: var(--text-muted); }
.hero-return-label {
@@ -356,9 +344,10 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-card) 16px;
margin-bottom: var(--space-section);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 10px;
box-shadow: var(--shadow);
}
.section-head {
@@ -376,15 +365,15 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.placed-time {
font-size: 11px;
color: var(--neutral);
color: var(--text-muted);
font-weight: 600;
}
.match-name {
font-size: 16px;
font-weight: 700;
font-size: 17px;
font-weight: 900;
color: var(--text);
line-height: 1.35;
line-height: 1.3;
margin-bottom: 10px;
}
@@ -442,35 +431,35 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.score-period {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.score-value {
font-size: 15px;
font-weight: 700;
font-size: 16px;
font-weight: 900;
color: var(--text);
}
.score-value.muted {
color: var(--neutral);
color: var(--text-muted);
font-size: 13px;
}
.no-score {
font-size: 11.5px;
color: var(--neutral);
color: var(--text-muted);
font-style: italic;
}
/* section title */
.section-title {
font-size: 11px;
font-size: 10.5px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 10px;
}
@@ -488,12 +477,12 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
gap: 10px;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 9px;
border-radius: 8px;
padding: 10px 12px;
}
.leg-row.won { border-color: rgba(46, 204, 113, 0.35); }
.leg-row.lost { border-color: rgba(255, 255, 255, 0.35); }
.leg-row.won { border-color: rgba(16, 185, 129, 0.3); }
.leg-row.lost { border-color: rgba(239, 68, 68, 0.3); }
.leg-left {
display: flex;
@@ -508,7 +497,7 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--bg-card);
background: var(--secondary);
color: var(--text-muted);
font-size: 10px;
font-weight: 800;
@@ -518,8 +507,8 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
margin-top: 1px;
}
.leg-num.won { background: rgba(46, 204, 113, 0.18); color: var(--success); }
.leg-num.lost { background: rgba(255, 255, 255, 0.18); color: var(--primary); }
.leg-num.won { background: rgba(16, 185, 129, 0.15); color: var(--success); }
.leg-num.lost { background: rgba(239, 68, 68, 0.15); color: var(--danger); }
.leg-body {
display: flex;
@@ -553,7 +542,7 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
}
.leg-scores .muted-score {
color: var(--neutral);
color: var(--text-muted);
}
.leg-right {
@@ -566,23 +555,23 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.leg-odds-val {
font-size: 15px;
font-weight: 700;
color: var(--text);
font-weight: 900;
color: var(--primary);
}
.leg-result-badge {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
padding: 2px 8px;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 10px;
}
.leg-result-badge.won { color: var(--success); background: rgba(46, 204, 113, 0.12); }
.leg-result-badge.lost { color: var(--primary); background: rgba(255, 255, 255, 0.1); }
.leg-result-badge.push { color: var(--text-muted); background: var(--bg-card); }
.leg-result-badge.pending { color: var(--neutral); background: var(--bg-body); }
.leg-result-badge.won { color: var(--success); background: rgba(16, 185, 129, 0.12); }
.leg-result-badge.lost { color: var(--danger); background: rgba(239, 68, 68, 0.12); }
.leg-result-badge.push { color: var(--text-muted); background: var(--secondary); }
.leg-result-badge.pending { color: var(--text-muted); background: var(--bg-body); }
/* summary */
.summary-rows {
@@ -598,7 +587,7 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 13px;
color: var(--text-muted);
color: var(--text);
font-weight: 600;
}
@@ -607,34 +596,35 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
}
.sum-row.muted {
color: var(--neutral);
color: var(--text-muted);
font-size: 11px;
}
.odds-val {
color: var(--text);
font-weight: 700;
color: var(--warning);
font-weight: 800;
}
.amt-won {
color: var(--success);
font-weight: 700;
font-weight: 900;
font-size: 15px;
}
.amt-pending {
color: var(--text-muted);
font-weight: 700;
color: var(--warning);
font-weight: 900;
}
.amt-lost {
color: var(--primary);
font-weight: 700;
color: var(--danger);
font-weight: 900;
font-size: 15px;
}
.bet-no-val {
font-family: monospace;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
</style>

View File

@@ -117,11 +117,11 @@ const pullIndicatorStyle = () => ({
<template>
<div class="cashback-page">
<header class="top-bar sub-toolbar">
<div class="top-bar">
<button type="button" class="back-btn" @click="router.back()">
&#x2039; {{ t('history.back') }}
</button>
</header>
</div>
<div class="pull-indicator" :style="pullIndicatorStyle()">
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
@@ -202,7 +202,7 @@ const pullIndicatorStyle = () => ({
.back-btn {
background: none;
border: none;
color: var(--primary-light);
color: var(--primary);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
@@ -243,18 +243,18 @@ const pullIndicatorStyle = () => ({
gap: 4px;
padding: 18px 16px 16px;
margin-bottom: 14px;
border-radius: var(--radius);
background: var(--gradient-card);
border: 1px solid var(--border-gold-soft);
border-radius: 12px;
background: linear-gradient(135deg, rgba(244, 162, 97, 0.15), var(--bg-card));
border: 1px solid var(--border);
text-align: center;
box-shadow: var(--shadow);
}
.hero-amount {
font-size: 28px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
line-height: 1.2;
text-shadow: 0 0 20px var(--surface-accent);
}
.hero-sub {
@@ -273,11 +273,8 @@ const pullIndicatorStyle = () => ({
}
.list-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--radius-lg);
overflow: hidden;
backdrop-filter: blur(10px);
}
.record-row {
@@ -290,7 +287,7 @@ const pullIndicatorStyle = () => ({
}
.record-row--highlight {
background: rgba(255, 255, 255, 0.1);
background: rgba(244, 162, 97, 0.1);
box-shadow: inset 3px 0 0 var(--primary);
}
@@ -318,7 +315,7 @@ const pullIndicatorStyle = () => ({
.row-amount {
font-size: 17px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
}
.row-period {
@@ -331,9 +328,9 @@ const pullIndicatorStyle = () => ({
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--primary-light);
background: var(--surface-accent);
border: 1px solid var(--border-gold-soft);
color: var(--warning);
background: rgba(255, 179, 0, 0.12);
border: 1px solid rgba(255, 179, 0, 0.3);
border-radius: 999px;
padding: 3px 10px;
}
@@ -356,7 +353,7 @@ const pullIndicatorStyle = () => ({
padding-top: 8px;
border-top: 1px dashed var(--border);
font-size: 11px;
color: var(--neutral);
color: var(--text-muted);
}
.row-batch {
@@ -387,7 +384,7 @@ const pullIndicatorStyle = () => ({
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--neutral);
color: var(--text-muted);
}
.sentinel {
@@ -403,7 +400,7 @@ const pullIndicatorStyle = () => ({
.end-hint {
text-align: center;
font-size: 12px;
color: var(--neutral);
color: var(--text-muted);
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
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 { useOnLocaleChange } from '../composables/useOnLocaleChange';
@@ -59,8 +60,10 @@ interface LeagueGroup {
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const mainTab = ref<MainTab>('matches');
const searchQuery = ref('');
const filterState = ref({
time: 'all' as 'all' | 'today' | 'early',
status: 'open' as 'all' | 'open' | 'settled',
@@ -96,10 +99,23 @@ function normalizeLeagueName(name: string): string {
.trim();
}
function syncSearchFromRoute() {
const q = route.query.search;
searchQuery.value = typeof q === 'string' ? q : '';
}
function matchesSearchKeyword(m: Match, keyword: string) {
if (!keyword) return true;
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
return haystack.includes(keyword);
}
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return [];
const now = filterNow.value;
const keyword = searchQuery.value.trim().toLowerCase();
return matches.value.filter((m) => {
if (!matchesSearchKeyword(m, keyword)) return false;
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
@@ -196,6 +212,16 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
const leagueGroups = computed(() => buildLeagueGroups(filteredMatches.value));
const isSearchActive = computed(() => searchQuery.value.trim().length > 0);
const sortedFilteredMatches = computed(() =>
[...filteredMatches.value].sort(
(a, b) =>
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
),
);
const expandedLeagues = ref(new Set<string>());
watch(leagueGroups, (groups) => {
@@ -226,9 +252,15 @@ function selectMainTab(tab: MainTab) {
onActivated(() => {
filterNow.value = new Date();
filterState.value.time = 'all';
syncSearchFromRoute();
void loadSummary(true, true);
});
watch(
() => route.query.search,
() => syncSearchFromRoute(),
);
function goMatch(id: string) {
router.push(`/match/${id}`);
}
@@ -247,7 +279,7 @@ function goMatch(id: string) {
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'matches' }"
:class="{ active: mainTab === 'matches', 'tab-gold-active': mainTab === 'matches' }"
@click="selectMainTab('matches')"
>
<span class="tab-icon"></span>
@@ -256,7 +288,7 @@ function goMatch(id: string) {
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'outright' }"
:class="{ active: mainTab === 'outright', 'tab-gold-active': mainTab === 'outright' }"
@click="selectMainTab('outright')"
>
<span class="tab-icon">🏆</span>
@@ -265,11 +297,22 @@ function goMatch(id: string) {
</div>
<div :class="['tab-panel', { 'tab-panel--hidden': mainTab !== 'matches' }]">
<div class="search-bar">
<span class="search-icon" aria-hidden="true"></span>
<input
v-model="searchQuery"
type="search"
:placeholder="t('search.placeholder')"
enterkeyhint="search"
autocomplete="off"
/>
</div>
<div class="filters-bar">
<div class="phase-filter">
<div class="league-dropdown">
<button type="button" class="dropdown-trigger" @click="dropdownOpen = !dropdownOpen">
<span class="dropdown-text">{{ selectedLeagueName || t('bet.filter_leagues_all') }}</span>
<span>{{ selectedLeagueName || t('bet.filter_leagues_all') }}</span>
<span class="arrow-icon" :class="{ open: dropdownOpen }"></span>
</button>
<div v-if="dropdownOpen" class="dropdown-backdrop" @click="dropdownOpen = false"></div>
@@ -340,7 +383,22 @@ function goMatch(id: string) {
</div>
<template v-else>
<div>
<div v-if="leagueGroups.length" class="league-list">
<div v-if="isSearchActive && sortedFilteredMatches.length" class="search-results">
<p class="search-results-meta">
{{ t('search.results_count', { count: sortedFilteredMatches.length }) }}
</p>
<div class="search-match-list">
<article
v-for="match in sortedFilteredMatches"
:key="match.id"
class="search-result-item"
>
<p class="search-result-league">{{ normalizeLeagueName(match.leagueName) }}</p>
<MatchBetCard :match="match" @bet="goMatch" />
</article>
</div>
</div>
<div v-else-if="!isSearchActive && leagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in leagueGroups"
:key="group.leagueId"
@@ -355,7 +413,7 @@ function goMatch(id: string) {
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
<p>{{ isSearchActive ? t('search.no_results') : t('bet.no_matches') }}</p>
</div>
</div>
</template>
@@ -382,6 +440,7 @@ function goMatch(id: string) {
}
.bet-page {
margin: 0 -16px;
padding-bottom: 8px;
background: var(--bg-body);
}
@@ -406,13 +465,8 @@ function goMatch(id: string) {
.main-tabs {
display: flex;
gap: 2px;
margin: 0 0 var(--space-section);
padding: 3px;
background: var(--gradient-card-soft);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius-sm);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
gap: 8px;
padding: 0 12px 12px;
}
.main-tab {
@@ -421,23 +475,23 @@ function goMatch(id: string) {
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 8px;
border-radius: calc(var(--radius-sm) - 3px);
background: transparent;
border: none;
padding: 10px 8px;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
font-weight: 600;
position: relative;
box-shadow: none;
transition: background 0.18s, color 0.18s;
box-shadow: var(--shadow);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.main-tab.active {
font-weight: 600;
background: var(--bg-elevated);
color: var(--text);
box-shadow: inset 0 0 0 1px var(--border);
font-weight: 700;
background: rgba(244, 162, 97, 0.1);
border-color: var(--primary);
color: var(--primary-light);
}
.tab-icon {
@@ -449,35 +503,20 @@ function goMatch(id: string) {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 0 0 var(--space-section);
background: var(--bg-body);
}
.time-tab.active,
.status-toggle-btn.active {
color: var(--text);
background: var(--surface-active);
font-weight: 600;
}
.time-tab.active {
box-shadow: inset 0 0 0 1px var(--border-gold);
gap: 12px;
padding: 0 16px 12px;
}
.phase-filter {
padding: 0;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.right-filters {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.time-tabs {
@@ -485,11 +524,10 @@ function goMatch(id: string) {
align-items: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: 6px;
padding: 2px;
height: 28px;
height: 24px;
box-sizing: border-box;
flex-shrink: 0;
}
.time-tab {
@@ -500,51 +538,54 @@ function goMatch(id: string) {
color: var(--text-muted);
background: transparent;
border: none;
border-radius: calc(var(--radius-sm) - 2px);
border-radius: 4px;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
}
.time-tab.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
font-weight: 600;
}
.right-filters .divider {
width: 1px;
height: 14px;
height: 12px;
background: var(--border);
}
.status-toggle-btn {
padding: 0 10px;
height: 28px;
padding: 0 8px;
height: 24px;
box-sizing: border-box;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: 6px;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
}
.status-toggle-btn.active {
border-color: var(--border-gold);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
border-color: var(--primary);
font-weight: 600;
}
/* League Dropdown Selector */
.league-dropdown {
position: relative;
display: inline-block;
width: 100%;
}
.dropdown-trigger {
@@ -553,24 +594,14 @@ function goMatch(id: string) {
gap: 6px;
padding: 0 10px;
font-size: 11px;
font-weight: 600;
font-weight: 500;
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: 6px;
cursor: pointer;
height: 28px;
height: 24px;
box-sizing: border-box;
width: 100%;
max-width: 100%;
transition: border-color 0.2s ease, background 0.2s ease;
}
.dropdown-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-trigger .arrow-icon {
@@ -597,48 +628,41 @@ function goMatch(id: string) {
top: calc(100% + 4px);
left: 0;
z-index: 99;
min-width: 170px;
max-width: 240px;
min-width: 140px;
max-width: 200px;
max-height: 200px;
overflow-y: auto;
background: var(--dropdown-bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 6px 0;
background: var(--bg-card-elevated);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
padding: 4px 0;
}
.dropdown-item {
padding: 8px 14px;
font-size: 13px;
color: var(--text-muted);
padding: 8px 12px;
font-size: 12px;
color: var(--text);
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid var(--border);
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover,
.dropdown-item:active {
background: rgba(255, 255, 255, 0.08);
background: var(--bg-body);
}
.dropdown-item.active {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
color: var(--primary-light);
background: rgba(244, 162, 97, 0.1);
font-weight: 600;
}
.league-list {
padding: 4px 0 0;
padding: 4px 12px 0;
}
.state,
@@ -663,4 +687,72 @@ function goMatch(id: string) {
.outright-tab {
min-height: 0;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin: 0 12px 8px;
padding: 0 10px;
height: 32px;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
}
.search-icon {
color: var(--primary-light);
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: var(--text);
font-size: 12px;
font-weight: 500;
line-height: 1.2;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.search-bar input::placeholder {
color: var(--text-muted);
}
.search-results {
padding: 0 12px;
}
.search-results-meta {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 8px;
}
.search-match-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.search-result-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.search-result-league {
font-size: 11px;
font-weight: 600;
color: var(--primary-light);
line-height: 1.3;
}
</style>

View File

@@ -66,35 +66,37 @@ function formatKickoff(startTime: string) {
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<div class="hot-tabs" role="tablist" :aria-label="t('home.hot_matches')">
<button
type="button"
role="tab"
class="hot-tab"
:class="{ active: activeTab === 'hot' }"
:aria-selected="activeTab === 'hot'"
@click="activeTab = 'hot'"
<section class="hot-panel">
<div class="hot-tabs" role="tablist" :aria-label="t('home.hot_matches')">
<button
type="button"
role="tab"
class="hot-tab"
:class="{ active: activeTab === 'hot' }"
:aria-selected="activeTab === 'hot'"
@click="activeTab = 'hot'"
>
{{ t('home.hot_tab') }}
</button>
<button
type="button"
role="tab"
class="hot-tab"
:class="{ active: activeTab === 'upcoming' }"
:aria-selected="activeTab === 'upcoming'"
@click="activeTab = 'upcoming'"
>
{{ t('home.upcoming_tab') }}
</button>
</div>
<div
v-for="(match, index) in displayedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
@click="goMatch(match.id)"
>
{{ t('home.hot_tab') }}
</button>
<button
type="button"
role="tab"
class="hot-tab"
:class="{ active: activeTab === 'upcoming' }"
:aria-selected="activeTab === 'upcoming'"
@click="activeTab = 'upcoming'"
>
{{ t('home.upcoming_tab') }}
</button>
</div>
<div
v-for="(match, index) in displayedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
@click="goMatch(match.id)"
>
<div class="match-info">
<div class="match-teams">{{ match.homeTeamName }} vs {{ match.awayTeamName }}</div>
<div class="match-time">{{ formatKickoff(match.startTime) }}</div>
@@ -110,11 +112,11 @@ function formatKickoff(startTime: string) {
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
<defs>
<linearGradient :id="`hzBoltGrad-${match.id}`" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#5eb8ff" stop-opacity="0.2" />
<stop offset="35%" stop-color="#b8ecff" stop-opacity="1" />
<stop offset="50%" stop-color="#ffffff" stop-opacity="1" />
<stop offset="65%" stop-color="#ffd080" stop-opacity="1" />
<stop offset="100%" stop-color="#ff9040" stop-opacity="0.2" />
<stop offset="0%" stop-color="#FFB980" stop-opacity="0.2" />
<stop offset="35%" stop-color="#FFDDD2" stop-opacity="1" />
<stop offset="50%" stop-color="#FFFFFF" stop-opacity="1" />
<stop offset="65%" stop-color="#FFDDD2" stop-opacity="1" />
<stop offset="100%" stop-color="#FFDDD2" stop-opacity="0.2" />
</linearGradient>
</defs>
<path
@@ -140,10 +142,11 @@ function formatKickoff(startTime: string) {
</div>
</div>
<div v-if="!loading && !displayedMatches.length" class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ emptyMessage }}</p>
</div>
<div v-if="!loading && !displayedMatches.length" class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ emptyMessage }}</p>
</div>
</section>
</div>
</template>
@@ -156,34 +159,153 @@ function formatKickoff(startTime: string) {
transition: height 0.15s ease;
}
.hot-panel {
background: var(--glass-bg);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
overflow: hidden;
}
.hot-tabs {
display: flex;
gap: 8px;
margin: 14px 0;
gap: 0;
margin-bottom: 0;
background: rgba(15, 32, 39, 0.55);
border-bottom: 1px solid var(--border);
}
.hot-tab {
flex: 1;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
padding: 12px 6px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
transition: background 0.2s, border-color 0.2s, color 0.2s;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.hot-tab.active {
font-weight: 700;
background: var(--surface-active);
border-color: var(--primary);
color: var(--text);
color: var(--primary);
border-bottom-color: var(--primary);
}
.hot-tab:active {
opacity: 0.92;
.quick-entries {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-areas:
"a a b b"
"c d e f";
gap: 8px;
margin-bottom: 16px;
padding: 0 2px;
}
.qe-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 4px 10px;
border-radius: var(--radius-lg);
min-width: 0;
overflow: hidden;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.qe-item:active {
transform: scale(0.96);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.qe-item:nth-child(1) { grid-area: a; }
.qe-item:nth-child(2) { grid-area: b; }
.qe-item:nth-child(3) { grid-area: c; }
.qe-item:nth-child(4) { grid-area: d; }
.qe-item:nth-child(5) { grid-area: e; }
.qe-item:nth-child(6) { grid-area: f; }
.qe-item--wide {
flex-direction: row;
gap: 10px;
padding: 14px 12px;
justify-content: flex-start;
}
.qe-item.qe-item--wide .qe-icon {
width: 36px;
height: 36px;
border-radius: 8px;
}
.qe-item.qe-item--wide .qe-label {
font-size: 13px;
font-weight: 700;
}
.qe-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.qe-icon--bet {
background: rgba(244, 162, 97, 0.15);
color: #FFB980;
}
.qe-icon--recharge {
background: rgba(16, 185, 129, 0.15);
color: #10B981;
}
.qe-icon--bill {
background: rgba(255, 179, 0, 0.15);
color: #FFB300;
}
.qe-icon--history {
background: rgba(231, 111, 81, 0.15);
color: #E76F51;
}
.qe-label {
font-size: 11px;
font-weight: 600;
color: var(--text);
text-align: center;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: var(--text);
margin: 0 0 8px;
padding-left: 10px;
border-left: 3px solid var(--primary);
line-height: 1.3;
}
.hot-panel .match-card {
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
box-shadow: none;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.match-card {
@@ -192,32 +314,32 @@ function formatKickoff(startTime: string) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding: 14px 16px;
min-height: 72px;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: none;
background: var(--bg-card);
gap: 8px;
margin-bottom: 0;
padding: 10px 12px;
min-height: 60px;
border-radius: 0;
cursor: pointer;
transition: opacity 0.2s, transform 0.2s;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.hot-panel .match-card:last-of-type {
border-bottom: none;
}
.match-card::before {
content: '';
position: absolute;
inset: 0;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
opacity: 0.25;
background: linear-gradient(135deg, rgba(244, 162, 97, 0.06) 0%, transparent 60%);
z-index: 0;
pointer-events: none;
}
.match-card:active {
opacity: 0.92;
transform: scale(0.995);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.match-info,
@@ -232,14 +354,15 @@ function formatKickoff(startTime: string) {
}
.match-teams {
font-weight: 800;
margin-bottom: 8px;
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
font-size: 14px;
line-height: 1.3;
color: var(--text);
}
.match-time {
font-size: 13px;
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
@@ -248,15 +371,15 @@ function formatKickoff(startTime: string) {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 4px;
gap: 3px;
max-width: 46%;
}
.vs-arena {
position: relative;
flex-shrink: 0;
width: 64px;
height: 52px;
width: 52px;
height: 42px;
display: flex;
align-items: center;
justify-content: center;
@@ -277,7 +400,7 @@ function formatKickoff(startTime: string) {
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 4px rgba(120, 210, 255, 0.95)) drop-shadow(0 0 8px rgba(255, 180, 80, 0.55));
filter: drop-shadow(0 0 4px rgba(244, 162, 97, 0.6)) drop-shadow(0 0 8px rgba(255, 185, 128, 0.35));
opacity: 0;
}
@@ -302,11 +425,11 @@ function formatKickoff(startTime: string) {
z-index: 1;
background: linear-gradient(
90deg,
rgba(94, 184, 255, 0) 0%,
rgba(184, 236, 255, 0.95) 28%,
#fff 50%,
rgba(255, 208, 128, 0.95) 72%,
rgba(255, 144, 64, 0) 100%
rgba(255, 185, 128, 0) 0%,
rgba(244, 162, 97, 0.7) 28%,
#F4A261 50%,
rgba(255, 185, 128, 0.7) 72%,
rgba(109, 40, 217, 0) 100%
);
opacity: 0;
filter: blur(0.4px);
@@ -320,10 +443,10 @@ function formatKickoff(startTime: string) {
.vs-img {
position: relative;
z-index: 0;
width: 48px;
width: 40px;
height: auto;
object-fit: contain;
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
filter: drop-shadow(0 0 3px rgba(244, 162, 97, 0.25));
}
.match-card--live-anim .vs-img {
@@ -411,21 +534,21 @@ function formatKickoff(startTime: string) {
0%,
100% {
opacity: 0.82;
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
filter: drop-shadow(0 0 2px rgba(244, 162, 97, 0.2));
}
50% {
opacity: 1;
filter:
drop-shadow(0 0 3px rgba(255, 230, 140, 0.7))
drop-shadow(0 0 6px rgba(212, 175, 55, 0.35));
drop-shadow(0 0 3px rgba(244, 162, 97, 0.5))
drop-shadow(0 0 6px rgba(255, 185, 128, 0.25));
}
}
@media (prefers-reduced-motion: reduce) {
.vs-img {
animation: none;
filter: drop-shadow(0 0 3px rgba(212, 175, 55, 0.35));
filter: drop-shadow(0 0 3px rgba(244, 162, 97, 0.25));
}
.hz-path,
@@ -438,13 +561,14 @@ function formatKickoff(startTime: string) {
.empty {
text-align: center;
color: var(--text-muted);
padding: 40px 20px;
padding: 28px 16px;
font-weight: 600;
border-top: 1px solid var(--border);
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
width: 72px;
height: 72px;
margin-bottom: 10px;
}
</style>

View File

@@ -159,7 +159,11 @@ onActivated(() => {
</button>
</div>
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
<MessageListPanel
v-if="inboxEnabled"
v-show="activeTab === 'messages'"
@click-message="(id) => router.push(`/messages/${id}`)"
/>
<CustomerServicePanel v-if="activeTab === 'support'" />
<ConfirmDialog
@@ -202,7 +206,7 @@ onActivated(() => {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--text);
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
@@ -212,7 +216,7 @@ onActivated(() => {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
color: var(--text);
}
.hub-top-bar {
@@ -220,6 +224,8 @@ onActivated(() => {
border-bottom: 1px solid var(--border);
}
.hub-tabs {
flex: 1;
display: flex;
@@ -246,7 +252,7 @@ onActivated(() => {
}
.hub-tab.active {
color: var(--text);
color: var(--primary);
border-bottom-color: var(--primary);
}
@@ -259,7 +265,7 @@ onActivated(() => {
padding: 0 5px;
border-radius: 9px;
background: var(--primary);
color: var(--on-primary);
color: var(--bg-body);
font-size: 11px;
font-weight: 700;
line-height: 18px;
@@ -276,9 +282,9 @@ onActivated(() => {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-active);
color: var(--text);
border-radius: var(--radius-sm);
background: rgba(244, 162, 97, 0.08);
color: var(--primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -292,6 +298,6 @@ onActivated(() => {
.action-btn.danger {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
color: rgba(239, 68, 68, 0.95);
color: var(--danger);
}
</style>

View File

@@ -14,8 +14,8 @@ 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 GoldSpinner from '../components/GoldSpinner.vue';
import VsBadge from '../components/VsBadge.vue';
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
import { formatLocalMatchDateTime } from '@thebet365/shared';
@@ -48,7 +48,6 @@ interface Selection {
selectionCode: string;
selectionName: string;
selectionDisplayName?: string;
status?: string;
odds: string;
oddsVersion: string;
}
@@ -290,7 +289,7 @@ function onPickSelection(selId: string, marketId: string) {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<header class="toolbar sub-toolbar sub-toolbar--sticky sub-toolbar--glass">
<header class="toolbar">
<button type="button" class="icon-btn" :aria-label="t('bet.back')" @click="router.back()"></button>
<span class="toolbar-title">{{ match?.leagueName ?? '' }}</span>
<div class="toolbar-actions">
@@ -312,8 +311,7 @@ function onPickSelection(selId: string, marketId: string) {
</div>
<template v-else-if="match">
<section class="match-hero" :class="{ 'match-hero--phase': matchPhase !== 'open' }">
<span v-if="matchPhase === 'open'" class="status-ribbon">{{ t('bet.status_open') }}</span>
<span v-else-if="matchPhase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
<span v-if="matchPhase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
<span v-else-if="matchPhase === 'closed_pending'" class="status-tag status-tag--pending">{{ phaseLabel }}</span>
<div class="hero-teams">
<!-- home -->
@@ -327,7 +325,32 @@ function onPickSelection(selId: string, marketId: string) {
<span class="hero-name">{{ match.homeTeamName }}</span>
</div>
<VsBadge size="lg" />
<!-- VS arena with lightning -->
<div class="vs-arena">
<svg class="hz-lightning" viewBox="0 0 72 28" aria-hidden="true">
<defs>
<linearGradient id="hzDetailGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFB980" stop-opacity="0.2" />
<stop offset="35%" stop-color="#FFDDD2" stop-opacity="1" />
<stop offset="50%" stop-color="#FFFFFF" stop-opacity="1" />
<stop offset="65%" stop-color="#FFDDD2" stop-opacity="1" />
<stop offset="100%" stop-color="#FFDDD2" stop-opacity="0.2" />
</linearGradient>
</defs>
<path
class="hz-path hz-path-main"
stroke="url(#hzDetailGrad)"
d="M1 14 H16 L20 5 L24 23 L28 9 L32 14 H40 L44 6 L48 22 L52 12 L56 14 H71"
/>
<path
class="hz-path hz-path-sub"
stroke="url(#hzDetailGrad)"
d="M3 19 H14 L18 15 L22 19 H50 L54 16 L58 19 H69"
/>
</svg>
<span class="hz-beam" aria-hidden="true" />
<img :src="vsImg" alt="" class="vs-img" />
</div>
<!-- away -->
<div class="hero-team">
@@ -360,7 +383,7 @@ function onPickSelection(selId: string, marketId: string) {
<!-- 我的投注 -->
<section v-if="myBets.length" class="my-bets-section">
<h3 class="my-bets-title">{{ t('history.my_bets') || '我的投注' }}</h3>
<h3 class="my-bets-title">{{ t('history.my_bets') }}</h3>
<div class="my-bets-list">
<div v-for="bet in myBets" :key="bet.betNo" class="my-bet-card" @click="router.push(`/bets/${bet.betNo}`)">
<div class="bet-header">
@@ -389,6 +412,8 @@ function onPickSelection(selId: string, marketId: string) {
:label="marketLabel(market)"
:promo-label="market.promoLabel?.trim() || ''"
:status-label="bettingOpen && !isMarketOpen(market) ? marketStatusLabel(market.status) : ''"
:has-market="true"
:expanded="true"
/>
<div class="market-panel-wrap" :class="{ locked: isMarketLocked(market) }">
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
@@ -427,9 +452,10 @@ function onPickSelection(selId: string, marketId: string) {
<style scoped>
.detail-page {
--detail-gutter-x: 0;
padding-top: var(--space-toolbar-top);
margin: 0 -16px;
padding-top: 0;
padding-bottom: 16px;
background: var(--bg-body);
}
.pull-indicator {
@@ -444,19 +470,22 @@ function onPickSelection(selId: string, marketId: string) {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px 8px;
gap: 8px;
}
.detail-page > .sub-toolbar {
padding-top: 0;
position: sticky;
top: 0;
z-index: 50;
margin-bottom: 8px;
background: var(--bg-glass);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(12px);
}
.toolbar-title {
flex: 1;
text-align: center;
font-size: 13px;
font-weight: 800;
font-weight: 700;
color: var(--primary-light);
white-space: nowrap;
overflow: hidden;
@@ -475,8 +504,8 @@ function onPickSelection(selId: string, marketId: string) {
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--border-gold-soft);
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--border);
background: var(--bg-body);
color: var(--primary-light);
font-size: 16px;
font-weight: 700;
@@ -488,92 +517,77 @@ function onPickSelection(selId: string, marketId: string) {
opacity: 0.45;
}
/* ── match hero ── */
/* -- match hero -- */
.match-hero {
position: relative;
isolation: isolate;
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
margin: 0 12px 10px;
padding: 14px 12px 16px;
overflow: hidden;
border-radius: var(--radius);
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: var(--shadow-card);
}
.match-hero::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
z-index: 2;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
}
.match-hero--phase {
opacity: 0.98;
}
/* ── hero status tag (top-left ribbon) ── */
/* -- hero status tag (top-right corner) -- */
.status-tag {
position: absolute;
top: 0;
left: 0;
right: auto;
right: 0;
z-index: 5;
font-size: 10px;
font-weight: 600;
padding: 4px 12px 4px 10px;
border-radius: 0 0 10px 0;
font-size: 11px;
font-weight: 700;
padding: 4px 14px;
border-radius: 0 6px 0 10px;
line-height: 1.3;
white-space: nowrap;
letter-spacing: 0;
}
.status-tag--settled {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1) 0%, rgba(0, 20, 40, 0.75) 100%);
background: var(--secondary);
color: var(--text-muted);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-right: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid var(--border);
border-left: 1px solid var(--border);
}
.status-tag--pending {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08) 0%, rgba(10, 53, 88, 0.6) 100%);
color: var(--text-muted);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-right: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 179, 0, 0.1);
color: #FFB300;
border-bottom: 1px solid rgba(255, 179, 0, 0.25);
border-left: 1px solid rgba(255, 179, 0, 0.25);
}
/* ── market panel smaller status tag ── */
/* -- market panel smaller status tag -- */
.market-status-tag {
position: absolute;
top: 0;
right: 0;
z-index: 5;
font-size: 10px;
font-weight: 800;
font-weight: 700;
padding: 3px 10px;
border-radius: 0 4px 0 8px;
line-height: 1.3;
white-space: nowrap;
letter-spacing: 0.02em;
}
.market-status-tag--settled {
background: linear-gradient(180deg, #2a2a3a 0%, #1a1a28 100%);
color: #8a9ab8;
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
border-left: 1px solid rgba(120, 140, 180, 0.3);
background: var(--secondary);
color: var(--text-muted);
border-bottom: 1px solid var(--border);
border-left: 1px solid var(--border);
}
.market-status-tag--pending {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(0, 51, 102, 0.95) 100%);
color: var(--primary-light);
border-bottom: 1px solid var(--border-gold-soft);
border-left: 1px solid var(--border-gold-soft);
background: rgba(255, 179, 0, 0.1);
color: #FFB300;
border-bottom: 1px solid rgba(255, 179, 0, 0.25);
border-left: 1px solid rgba(255, 179, 0, 0.25);
}
.market-status-tag--suspended {
@@ -585,7 +599,7 @@ function onPickSelection(selId: string, marketId: string) {
.market-status-tag--closed {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(40, 40, 55, 0.95) 100%);
color: var(--text-muted);
color: #8a9ab8;
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
border-left: 1px solid rgba(120, 140, 180, 0.3);
}
@@ -613,17 +627,16 @@ function onPickSelection(selId: string, marketId: string) {
position: relative;
z-index: 2;
margin: 8px 0 0;
font-size: 24px;
font-weight: 700;
color: var(--text);
font-size: 28px;
font-weight: 900;
color: var(--primary-light);
text-align: center;
letter-spacing: 0.02em;
}
.result-stats-section {
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
padding: var(--space-card);
border: 1px solid var(--border-gold-soft);
margin: 0 10px 12px;
padding: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-card);
box-shadow: var(--shadow);
@@ -641,8 +654,8 @@ function onPickSelection(selId: string, marketId: string) {
margin: 0;
font-size: 14px;
line-height: 1.2;
font-weight: 600;
color: var(--text);
font-weight: 700;
color: var(--primary-light);
}
.result-stats-head span {
@@ -686,8 +699,8 @@ function onPickSelection(selId: string, marketId: string) {
flex-shrink: 0;
font-size: 14px;
line-height: 1;
font-weight: 900;
color: #fff;
font-weight: 700;
color: var(--text);
}
.hero-teams {
@@ -710,8 +723,8 @@ function onPickSelection(selId: string, marketId: string) {
.hero-name {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
font-weight: 700;
color: var(--text);
text-align: center;
line-height: 1.25;
white-space: nowrap;
@@ -720,16 +733,124 @@ function onPickSelection(selId: string, marketId: string) {
max-width: 100%;
}
/* VS arena -- blue themed */
.vs-arena {
position: relative;
flex-shrink: 0;
width: 140px;
height: 126px;
display: flex;
align-items: center;
justify-content: center;
}
.hz-lightning {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
overflow: visible;
}
.hz-path {
fill: none;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 4px rgba(244, 162, 97, 0.6)) drop-shadow(0 0 8px rgba(255, 185, 128, 0.35));
opacity: 0;
}
.hz-path-main {
animation: hz-strike-main 2.6s ease-in-out infinite;
}
.hz-path-sub {
stroke-width: 1.6;
animation: hz-strike-sub 2.6s ease-in-out infinite;
animation-delay: 0.12s;
}
.hz-beam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
transform: translateY(-50%);
pointer-events: none;
z-index: 1;
background: linear-gradient(
90deg,
rgba(255, 185, 128, 0) 0%,
rgba(244, 162, 97, 0.7) 28%,
#F4A261 50%,
rgba(255, 185, 128, 0.7) 72%,
rgba(109, 40, 217, 0) 100%
);
opacity: 0;
filter: blur(0.4px);
animation: hz-beam-flash 2.6s ease-in-out infinite;
}
.vs-img {
position: relative;
z-index: 0;
width: 126px;
height: auto;
object-fit: contain;
}
@keyframes hz-strike-main {
0%, 72%, 100% { opacity: 0; }
74% { opacity: 1; }
75% { opacity: 0.25; }
76% { opacity: 0.95; }
78% { opacity: 0; }
}
@keyframes hz-strike-sub {
0%, 74%, 100% { opacity: 0; }
76% { opacity: 0.85; }
77% { opacity: 0.2; }
78% { opacity: 0.7; }
80% { opacity: 0; }
}
@keyframes hz-beam-flash {
0%, 71%, 100% { opacity: 0; transform: translateY(-50%) scaleX(0.6); }
73% { opacity: 0.85; transform: translateY(-50%) scaleX(1); }
75% { opacity: 0.15; transform: translateY(-50%) scaleX(0.95); }
76% { opacity: 0.75; transform: translateY(-50%) scaleX(1); }
78% { opacity: 0; transform: translateY(-50%) scaleX(1.05); }
}
@keyframes vs-glow {
0%, 100% {
opacity: 0.82;
filter: drop-shadow(0 0 2px rgba(244, 162, 97, 0.2));
}
50% {
opacity: 1;
filter: drop-shadow(0 0 3px rgba(244, 162, 97, 0.5)) drop-shadow(0 0 6px rgba(255, 185, 128, 0.25));
}
}
@media (prefers-reduced-motion: reduce) {
.hz-path, .hz-beam { animation: none; opacity: 0; }
}
.markets-section {
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x)));
padding: 0 0 12px;
padding: 0 10px 8px;
}
.market-list {
display: flex;
flex-direction: column;
gap: 10px;
padding: 6px 0;
gap: 8px;
padding: 4px 0;
}
.market-group {
@@ -742,8 +863,8 @@ function onPickSelection(selId: string, marketId: string) {
}
.market-group.open {
border-color: var(--border-gold-soft);
box-shadow: none;
border-color: var(--border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.market-group :deep(.row) {
@@ -759,12 +880,12 @@ function onPickSelection(selId: string, marketId: string) {
width: calc(100% - 16px);
margin: 0 8px 10px;
padding: 9px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: var(--surface-accent);
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-body);
color: var(--primary-light);
font-size: 13px;
font-weight: 800;
font-weight: 700;
}
.state {
@@ -780,16 +901,16 @@ function onPickSelection(selId: string, marketId: string) {
padding: 2px 0 6px;
}
/* ── 我的投注 ── */
/* -- my bets -- */
.my-bets-section {
margin: 0 calc(-1 * (var(--space-page-x) - var(--detail-gutter-x))) 12px;
padding: 0 12px;
margin-bottom: 12px;
}
.my-bets-title {
font-size: 14px;
font-weight: 700;
color: var(--text-muted);
color: var(--text);
margin: 0 0 8px;
}
@@ -806,10 +927,11 @@ function onPickSelection(selId: string, marketId: string) {
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s;
box-shadow: var(--shadow);
}
.my-bet-card:active {
background: var(--bg-hover);
background: var(--bg-body);
}
.bet-header {
@@ -822,7 +944,7 @@ function onPickSelection(selId: string, marketId: string) {
.bet-type {
font-size: 12px;
font-weight: 700;
color: #fff;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -839,18 +961,18 @@ function onPickSelection(selId: string, marketId: string) {
}
.bet-status-won {
background: rgba(61, 184, 101, 0.15);
color: #2ECC71;
background: rgba(16, 185, 129, 0.1);
color: #10B981;
}
.bet-status-lost {
background: rgba(224, 80, 80, 0.15);
color: #e05050;
background: rgba(239, 68, 68, 0.1);
color: #EF4444;
}
.bet-status-pending {
background: rgba(232, 200, 74, 0.15);
color: #e8c84a;
background: rgba(255, 179, 0, 0.1);
color: #FFB300;
}
.bet-footer {
@@ -866,7 +988,7 @@ function onPickSelection(selId: string, marketId: string) {
.bet-return {
font-size: 13px;
font-weight: 800;
font-weight: 700;
}
</style>

View File

@@ -15,13 +15,26 @@ import {
} from '../composables/usePlayerMessages';
import { useInboxFeature } from '../composables/useInboxFeature';
const props = withDefaults(
defineProps<{
embedded?: boolean;
/** 悬浮信箱内嵌时使用,不走路由 */
id?: string;
}>(),
{ embedded: false },
);
const emit = defineEmits<{ back: [] }>();
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { loadMessageDetail, markMessageRead, deleteMessage } = usePlayerMessages();
const { hubRoute } = useInboxFeature();
const messageId = computed(() => String(route.params.id ?? ''));
const messageId = computed(() => props.id ?? String(route.params.id ?? ''));
const isPopupEmbedded = computed(() => props.embedded && Boolean(props.id));
const isHubEmbedded = computed(() => props.embedded && !props.id);
const message = ref<PlayerMessage | null>(null);
const loading = ref(false);
const error = ref(false);
@@ -49,11 +62,23 @@ const contentPromoId = computed(() => {
});
function goBack() {
if (isPopupEmbedded.value) {
emit('back');
return;
}
if (isHubEmbedded.value) {
void router.push(hubRoute.value);
return;
}
router.back();
}
function goList() {
router.push(hubRoute.value);
if (isPopupEmbedded.value) {
emit('back');
return;
}
void router.push(hubRoute.value);
}
function viewContentPromo() {
@@ -124,7 +149,11 @@ async function confirmDelete() {
try {
await deleteMessage(messageId.value);
deleteConfirmVisible.value = false;
router.replace(hubRoute.value);
if (isPopupEmbedded.value) {
emit('back');
} else {
void router.replace(hubRoute.value);
}
} finally {
deleting.value = false;
}
@@ -144,8 +173,8 @@ watch(messageId, () => {
</script>
<template>
<div class="message-detail">
<header class="page-header">
<div class="message-detail" :class="{ 'is-embedded': embedded, 'is-hub-embedded': isHubEmbedded, 'is-popup-embedded': isPopupEmbedded }">
<header v-if="!isHubEmbedded" class="page-header">
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack"></button>
<h1>{{ t('messages.detail_title') }}</h1>
<button
@@ -160,13 +189,32 @@ watch(messageId, () => {
</button>
</header>
<header v-else class="page-header page-header--hub">
<button type="button" class="back-btn back-btn--hub" :aria-label="t('messages.back')" @click="goBack">
{{ t('messages.back') }}
</button>
<h1>{{ t('messages.detail_title') }}</h1>
<button
v-if="message"
type="button"
class="delete-header-btn"
:aria-label="t('messages.delete')"
:disabled="deleting"
@click="onDelete"
>
{{ t('messages.delete') }}
</button>
</header>
<div v-if="loading && !message" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="error || !message" class="empty">
<p>{{ t('messages.not_found') }}</p>
<button type="button" class="link-btn" @click="goList">{{ t('messages.view_all') }}</button>
<button v-if="!isHubEmbedded" type="button" class="link-btn" @click="goList">
{{ isPopupEmbedded ? t('common.back') : t('messages.view_all') }}
</button>
</div>
<article v-else class="detail-article">
@@ -241,9 +289,18 @@ watch(messageId, () => {
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--text);
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.back-btn--hub {
width: auto;
min-width: 36px;
padding: 0 12px;
font-size: 14px;
font-weight: 600;
}
.page-header h1 {
@@ -251,15 +308,15 @@ watch(messageId, () => {
flex: 1;
font-size: 18px;
font-weight: 700;
color: #fff;
color: var(--text);
}
.delete-header-btn {
border: 1px solid rgba(239, 68, 68, 0.35);
border-radius: 8px;
border-radius: var(--radius-sm);
padding: 6px 10px;
background: rgba(239, 68, 68, 0.08);
color: rgba(239, 68, 68, 0.95);
color: var(--danger);
font-size: 12px;
font-weight: 600;
}
@@ -275,13 +332,13 @@ watch(messageId, () => {
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #888;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
}
.status-badge.unread {
color: var(--text);
background: var(--surface-active);
color: var(--primary);
background: rgba(244, 162, 97, 0.12);
}
.state,
@@ -296,10 +353,10 @@ watch(messageId, () => {
.link-btn {
border: 1px solid var(--border);
border-radius: 8px;
border-radius: var(--radius-sm);
padding: 8px 14px;
background: var(--surface-active);
color: var(--text);
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
font-size: 13px;
font-weight: 600;
}
@@ -319,14 +376,14 @@ watch(messageId, () => {
font-size: 20px;
font-weight: 700;
line-height: 1.45;
color: #fff;
color: var(--text);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #d0d0d0;
color: var(--text-secondary);
}
.rich-body :deep(p) {
@@ -352,8 +409,7 @@ watch(messageId, () => {
}
.rich-body :deep(a) {
color: var(--primary-light);
text-decoration: underline;
color: var(--primary);
}
.reason-box {
@@ -367,7 +423,7 @@ watch(messageId, () => {
.reason-label {
margin: 0 0 8px;
font-size: 12px;
color: rgba(239, 68, 68, 0.95);
color: var(--danger);
font-weight: 700;
}
@@ -375,7 +431,7 @@ watch(messageId, () => {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--text);
color: var(--text-secondary);
}
.action-btn {
@@ -383,11 +439,38 @@ watch(messageId, () => {
width: 100%;
padding: 11px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface-active);
color: var(--text);
border-radius: var(--radius-sm);
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.is-hub-embedded {
padding: 0 4px 24px;
}
.is-hub-embedded .page-header--hub {
padding: 0 0 16px;
}
.is-hub-embedded .page-header--hub h1 {
margin: 0;
flex: 1;
font-size: 16px;
font-weight: 800;
}
.is-popup-embedded {
padding: 12px 14px 16px;
}
.is-popup-embedded .page-header {
padding: 0 0 12px;
}
.is-popup-embedded .page-header h1 {
font-size: 15px;
}
</style>

View File

@@ -112,7 +112,7 @@ const pullIndicatorStyle = () => ({
</div>
<template v-else>
<BetStatsPanel class="stats-panel" :items="items" />
<BetStatsPanel :items="items" />
<div class="filter-tabs">
<button
@@ -128,9 +128,7 @@ const pullIndicatorStyle = () => ({
</div>
<template v-if="items.length">
<div class="bet-list">
<BetHistoryCard v-for="bet in items" :key="bet.betNo" :bet="bet" />
</div>
<div ref="sentinel" class="sentinel" />
@@ -153,16 +151,6 @@ const pullIndicatorStyle = () => ({
padding-bottom: 24px;
}
.stats-panel {
margin-bottom: 10px;
}
.bet-list {
display: flex;
flex-direction: column;
gap: var(--space-section);
}
.pull-indicator {
display: flex;
align-items: center;
@@ -173,8 +161,8 @@ const pullIndicatorStyle = () => ({
.filter-tabs {
display: flex;
gap: 8px;
margin-bottom: var(--space-section);
gap: 6px;
margin-bottom: 12px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
@@ -184,21 +172,20 @@ const pullIndicatorStyle = () => ({
.filter-tab {
flex-shrink: 0;
padding: 8px 16px;
padding: 7px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
font-weight: 700;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
transition: background 0.2s, border-color 0.2s, color 0.2s;
transition: all 0.2s;
}
.filter-tab.active {
color: var(--text);
background: var(--surface-accent);
color: var(--primary);
background: rgba(244, 162, 97, 0.15);
border-color: var(--primary);
font-weight: 700;
}
.state {

View File

@@ -149,7 +149,7 @@ onUnmounted(() => {
border: 1px solid rgba(201, 162, 39, 0.22);
font-size: 12px;
font-weight: 600;
color: #c9a227;
color: var(--primary);
}
.teams-grid {

View File

@@ -185,7 +185,7 @@ function back() {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<header class="page-head sub-toolbar">
<header class="page-head">
<button type="button" class="back-btn" @click="back"> {{ t('profile.back') }}</button>
<h2 class="page-title">{{ t('profile.edit') }}</h2>
</header>
@@ -308,7 +308,7 @@ function back() {
<style scoped>
.edit-page {
padding: 0 0 20px;
padding: 8px 0 20px;
display: flex;
flex-direction: column;
gap: 12px;
@@ -338,7 +338,7 @@ function back() {
.page-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
letter-spacing: 0.04em;
}
@@ -346,7 +346,7 @@ function back() {
.form-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: 8px;
}
.avatar-card {
@@ -361,12 +361,12 @@ function back() {
width: 80px;
height: 80px;
border-radius: 50%;
border: 2px solid var(--border-gold-soft);
border: 2px solid var(--border);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: var(--gradient-card);
background: var(--secondary);
}
.avatar-img {
@@ -379,15 +379,15 @@ function back() {
.avatar-change-btn {
padding: 7px 18px;
border-radius: 999px;
border: 1px solid var(--border-gold-soft);
background: var(--surface-accent);
color: var(--primary-light);
border: 1px solid var(--primary);
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
font-size: 13px;
font-weight: 700;
}
.form-card {
padding: var(--space-card);
padding: 16px;
}
.section-title {
@@ -471,8 +471,8 @@ function back() {
border: none;
border-left: 1px solid var(--border);
border-radius: 0 6px 6px 0;
background: var(--surface-accent);
color: var(--primary-light);
background: var(--bg-body);
color: var(--primary);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
@@ -512,8 +512,8 @@ label {
.field-input:focus {
outline: none;
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
border-color: var(--border-active);
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.2);
}
.field-input:-webkit-autofill,
@@ -525,7 +525,7 @@ label {
}
.field-input::placeholder {
color: var(--neutral);
color: rgba(148, 163, 184, 0.5);
}
.readonly {
@@ -555,7 +555,7 @@ label {
}
.msg.ok {
color: var(--primary-light);
color: var(--success);
}
.msg.err {

View File

@@ -1,25 +1,27 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { ref, onMounted, onActivated, computed } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
import { sumCashbackFromStats } from '../utils/walletStats';
import WalletBalanceCard from '../components/WalletBalanceCard.vue';
import walletBg from '../assets/images/wallet-bg.webp';
const { t } = useI18n();
const { t, locale } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { initFromUser } = useAppLocale();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
const loading = ref(true);
const error = ref(false);
const rulesExpanded = ref(false);
const cashbackTotal = ref('0');
const displayAmount = ref(0);
@@ -47,7 +49,14 @@ async function fetchCashbackTotal() {
// Fallback: sum wallet cashback credits when batch detail API is unavailable.
try {
const { data } = await api.get('/player/wallet/transactions/stats');
cashbackTotal.value = sumCashbackFromStats(data.data).toString();
const byType = data.data?.byType ?? [];
let sum = 0;
for (const g of byType) {
if (['CASHBACK', 'CASHBACK_DEPOSIT'].includes(g.transactionType?.toUpperCase())) {
sum += Math.abs(parseFloat(g.totalAmount ?? '0'));
}
}
cashbackTotal.value = sum.toString();
} catch {
// Keep default value
}
@@ -70,6 +79,28 @@ const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
async function changeLocale(code: string) {
await setLocale(code);
}
function logout() {
auth.logout();
router.push('/');
}
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';
});
</script>
<template>
@@ -91,47 +122,138 @@ const pullIndicatorStyle = () => ({
</div>
<template v-else>
<WalletBalanceCard
:available-balance="profileRaw?.wallet?.availableBalance"
:cashback-total="cashbackTotal"
:frozen-balance="profileRaw?.wallet?.frozenBalance"
/>
<div class="wallet-banner">
<img class="wallet-banner-img" :src="walletBg" alt="" />
<div class="wallet-banner-scrim" aria-hidden="true" />
<section class="settings-group settings-group--wallet">
<RouterLink to="/wallet/detail" class="settings-cell settings-cell--accent-entry">
<div class="card-overlay">
<div class="bank-card-top">
<div class="bank-card-top-left">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<div class="bank-card-brand-text">
<span class="brand-name">THEBET365</span>
<span class="bank-card-type">MEMBER</span>
</div>
</div>
<RouterLink to="/wallet/recharge" class="recharge-btn">
<span class="recharge-icon">+</span>
<span>{{ t('recharge.title') }}</span>
</RouterLink>
</div>
<div class="bank-card-balance">
<span class="bank-card-label">{{ t('wallet.balance') }}</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('wallet.card_holder') }}</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('cashback.total_received') }}</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.unsettled') }}</span>
<span class="bank-card-stat">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
</div>
</div>
<div class="quick-actions">
<RouterLink to="/wallet/detail" class="qa-item">
<span class="qa-icon qa-icon--wallet">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><path d="M21 12V7H5a2 2 0 010-4h14v4"/><path d="M3 5v14a2 2 0 002 2h16v-5"/><path d="M18 12a2 2 0 100 4 2 2 0 000-4z"/></svg>
</span>
<span class="qa-label">{{ t('wallet.view_all') }}</span>
</RouterLink>
<RouterLink to="/wallet/recharge" class="qa-item">
<span class="qa-icon qa-icon--recharge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><circle cx="12" cy="12" r="10"/><path d="M12 8v8M8 12h8"/></svg>
</span>
<span class="qa-label">{{ t('recharge.title') }}</span>
</RouterLink>
<RouterLink to="/profile/cashbacks" class="qa-item">
<span class="qa-icon qa-icon--cashback">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><circle cx="12" cy="12" r="8"/><path d="M12 8v4l2.5 2.5"/></svg>
</span>
<span class="qa-label">{{ t('wallet.view_cashbacks') }}</span>
</RouterLink>
<RouterLink to="/wallet/recharge/history" class="qa-item">
<span class="qa-icon qa-icon--history">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="24" height="24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M9 13h6M9 17h4"/></svg>
</span>
<span class="qa-label">{{ t('recharge.history_title') }}</span>
</RouterLink>
</div>
<section class="settings-group">
<RouterLink to="/profile/edit" class="settings-cell">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M9 5H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V9" />
<path d="M9 5a2 2 0 012-2h2a2 2 0 012 2v0M9 12h6M9 16h4" />
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20c0-3.5 3.1-6 7-6s7 2.5 7 6" />
</svg>
<span class="cell-label">{{ t('wallet.view_all') }}</span>
<span class="cell-label">{{ t('profile.edit') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/profile/cashbacks" class="settings-cell settings-cell--accent-entry">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="8" />
<path d="M12 8v4l2.5 2.5" />
</svg>
<span class="cell-label">{{ t('wallet.view_cashbacks') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<div class="rules-cell">
<button
type="button"
class="settings-cell rules-toggle"
:aria-expanded="rulesExpanded"
@click="rulesExpanded = !rulesExpanded"
>
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M7 4h10v16H7z" />
<path d="M10 8h6M10 12h6M10 16h4" />
</svg>
<span class="cell-label">{{ t('profile.rules_title') }}</span>
</span>
<span class="cell-chevron" :class="{ open: rulesExpanded }" aria-hidden="true"></span>
</button>
<div v-show="rulesExpanded" class="rules-body">
<p>{{ t('profile.rules_p1') }}</p>
<p>{{ t('profile.rules_p2') }}</p>
<p>{{ t('profile.rules_p3') }}</p>
<p>{{ t('profile.rules_p4') }}</p>
<p>{{ t('profile.rules_p5') }}</p>
</div>
</div>
<RouterLink to="/wallet/recharge/history" class="settings-cell settings-cell--accent-entry">
<span class="cell-main">
<div class="settings-cell settings-cell--stack">
<div class="cell-head">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M12 8v8M8 12h8" />
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c2.5 2.8 4 6 4 9s-1.5 6.2-4 9M12 3c-2.5 2.8-4 6-4 9s1.5 6.2 4 9" />
</svg>
<span class="cell-label">{{ t('recharge.history_title') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<span class="cell-label">{{ t('profile.language') }}</span>
</div>
<div class="lang-segment" role="group" :aria-label="t('profile.language')">
<button
v-for="item in locales"
:key="item.code"
type="button"
class="lang-opt"
:class="{ active: locale === item.code }"
@click="changeLocale(item.code)"
>
<LocaleFlag :locale="item.code" :size="14" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
</section>
<button type="button" class="logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</template>
</div>
</template>
@@ -146,7 +268,7 @@ const pullIndicatorStyle = () => ({
}
.profile-page {
padding: 8px 0 12px;
padding: 6px 0 10px;
}
.loading-state {
@@ -183,49 +305,320 @@ const pullIndicatorStyle = () => ({
}
.retry-btn:active {
background: var(--surface-active);
background: rgba(244, 162, 97, 0.15);
}
.wallet-banner {
position: relative;
width: 100%;
margin-bottom: 10px;
aspect-ratio: 1.75 / 1;
border-radius: 12px;
overflow: hidden;
box-shadow: var(--shadow);
}
.wallet-banner::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
pointer-events: none;
}
.wallet-banner-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-position: center center;
pointer-events: none;
user-select: none;
}
.wallet-banner-scrim {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(
135deg,
#0F2027 0%,
#F4A261 50%,
#203A43 100%
);
}
.card-overlay {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
flex-direction: column;
padding: 10px 14px 10px;
line-height: 1.3;
-webkit-font-smoothing: antialiased;
}
.bank-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-shrink: 0;
}
.bank-card-top-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.brand-logo {
width: 18px;
height: 18px;
flex-shrink: 0;
object-fit: contain;
}
.bank-card-brand-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.brand-name {
font-size: 11px;
font-weight: 700;
color: #FFFFFF;
letter-spacing: 0.14em;
line-height: 1;
}
.bank-card-type {
font-size: 8px;
font-weight: 800;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.6);
font-style: italic;
line-height: 1;
}
.bank-card-label {
display: block;
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.75);
line-height: 1.3;
}
.recharge-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 3px;
padding: 5px 10px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #FFFFFF;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
line-height: 1;
text-decoration: none;
transition: background 0.15s ease;
}
.recharge-btn:active {
background: rgba(255, 255, 255, 0.25);
}
.recharge-icon {
font-size: 12px;
font-weight: 700;
line-height: 1;
}
.bank-card-balance {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
min-height: 0;
padding: 1px 0;
gap: 3px;
}
.bank-card-number {
margin: 0;
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-weight: 700;
font-style: normal;
font-variant-numeric: tabular-nums;
color: #FFFFFF;
line-height: 1.1;
letter-spacing: 0.04em;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}
.balance-amount {
margin: 0;
}
.balance-amount--xl { font-size: clamp(22px, 5.6vw, 27px); }
.balance-amount--lg { font-size: clamp(19px, 5vw, 24px); }
.balance-amount--md { font-size: clamp(16px, 4.4vw, 20px); }
.balance-amount--sm { font-size: clamp(14px, 3.8vw, 17px); }
.balance-amount--xs { font-size: clamp(12px, 3.2vw, 14px); }
.bank-card-footer {
flex-shrink: 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.bank-card-field {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.bank-card-field--center { text-align: center; }
.bank-card-field--right { text-align: right; }
.bank-card-holder {
font-size: 12px;
font-weight: 700;
color: #FFFFFF;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.2;
word-break: break-all;
}
.bank-card-stat {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #FFFFFF;
line-height: 1.2;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-areas:
"a a b b"
"c c d d";
gap: 8px;
margin-bottom: 10px;
}
.qa-item:nth-child(1) { grid-area: a; }
.qa-item:nth-child(2) { grid-area: b; }
.qa-item:nth-child(3) { grid-area: c; }
.qa-item:nth-child(4) { grid-area: d; }
.qa-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 12px 10px;
border-radius: var(--radius-lg);
text-decoration: none;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
min-width: 0;
overflow: hidden;
}
.qa-item:active {
transform: scale(0.96);
box-shadow: none;
}
.qa-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.qa-icon--wallet {
background: rgba(244, 162, 97, 0.15);
color: var(--primary);
}
.qa-icon--recharge {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
}
.qa-icon--cashback {
background: rgba(255, 179, 0, 0.15);
color: var(--warning);
}
.qa-icon--history {
background: rgba(244, 162, 97, 0.15);
color: #F4A261;
}
.qa-icon--inbox {
background: rgba(14, 165, 233, 0.15);
color: #0EA5E9;
}
.qa-label {
font-size: 13px;
font-weight: 600;
color: var(--text);
text-align: left;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.settings-group {
position: relative;
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
box-shadow: var(--shadow-card-sm);
}
.settings-group::before {
content: '';
position: absolute;
top: 0;
left: 10%;
right: 10%;
height: 1px;
background: var(--gradient-card-shine);
pointer-events: none;
z-index: 1;
}
.settings-group--wallet {
margin-bottom: var(--space-section);
}
.settings-group--profile {
margin-bottom: 0;
}
.settings-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
gap: 10px;
width: 100%;
min-height: 48px;
padding: 0 16px;
min-height: 42px;
padding: 0 14px;
background: none;
color: var(--text);
font-size: 14px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid var(--border);
@@ -239,34 +632,12 @@ const pullIndicatorStyle = () => ({
background: var(--bg-hover);
}
.settings-cell--accent-entry {
position: relative;
min-height: 50px;
border-color: var(--border);
background: transparent;
box-shadow: none;
}
.settings-cell--accent-entry:active {
background: var(--surface-active);
}
.settings-cell--accent-entry .cell-label {
color: var(--text);
font-weight: 600;
}
.settings-cell--accent-entry .cell-chevron {
color: var(--text-muted);
opacity: 0.75;
}
.settings-cell--stack {
flex-direction: column;
align-items: stretch;
justify-content: center;
gap: 10px;
padding: 12px 16px 14px;
gap: 8px;
padding: 10px 14px 12px;
min-height: auto;
}
@@ -284,10 +655,6 @@ const pullIndicatorStyle = () => ({
color: var(--text-muted);
}
.settings-cell--accent-entry .cell-icon {
color: var(--primary-light);
}
.cell-head {
display: flex;
align-items: center;
@@ -317,7 +684,7 @@ const pullIndicatorStyle = () => ({
}
.rules-cell .rules-body {
padding: 0 16px 14px;
padding: 0 14px 12px;
border-bottom: 1px solid var(--border);
}
@@ -332,37 +699,41 @@ const pullIndicatorStyle = () => ({
align-items: center;
justify-content: center;
gap: 4px;
min-height: 34px;
padding: 0 6px;
border-radius: var(--radius-sm);
min-height: 30px;
min-width: 0;
padding: 0 5px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-body);
background: var(--bg-card);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lang-opt.active {
border-color: var(--primary);
color: var(--text);
background: var(--surface-accent);
color: var(--primary);
background: rgba(244, 162, 97, 0.12);
}
.logout-btn {
width: 100%;
margin-top: var(--space-section);
min-height: 44px;
padding: 0 16px;
border-radius: var(--radius);
border: 1px solid var(--border);
margin-top: 10px;
min-height: 40px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--danger);
background: var(--bg-card);
color: var(--danger);
font-size: 14px;
font-size: 13px;
font-weight: 700;
}
.logout-btn:active {
background: rgba(255, 69, 58, 0.08);
background: rgba(239, 68, 68, 0.12);
}
.rules-body {

View File

@@ -324,11 +324,11 @@ function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
.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--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; }
.audit-step--reopened .audit-dot { background: var(--primary); }
.reapply-btn {
display: block;

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { ref, onMounted, onUnmounted, watch } 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 { enrichDepositOrder, findDepositOrderById } from '../utils/depositOrderLookup';
import {
auditActionTone,
auditActorSecondary,
@@ -14,6 +15,7 @@ import {
} from '../utils/depositAuditDisplay';
const router = useRouter();
const route = useRoute();
const { t, locale } = useI18n();
interface DepositAuditLog {
@@ -85,7 +87,7 @@ const { pullDistance, spinning, progress } = usePullToRefresh({
});
onMounted(() => {
fetchOrders(1);
void fetchOrders(1).then(() => openDetailFromQuery());
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore.value && !loading.value) {
@@ -97,6 +99,10 @@ onMounted(() => {
if (sentinel.value) observer.observe(sentinel.value);
});
watch(() => route.query.orderId, () => {
void openDetailFromQuery();
});
onUnmounted(() => {
observer?.disconnect();
});
@@ -121,14 +127,24 @@ function goRecharge() {
router.push('/wallet/recharge');
}
function openDetail(order: DepositOrder) {
selectedOrder.value = order;
async function openDetail(order: DepositOrder) {
selectedOrder.value = await enrichDepositOrder(order);
}
function closeDetail() {
selectedOrder.value = null;
}
async function openDetailFromQuery() {
const orderId = route.query.orderId;
if (typeof orderId !== 'string' || !orderId) return;
const existing = items.value.find((order) => order.id === orderId);
const order = existing ?? await findDepositOrderById(orderId);
if (order) await openDetail(order);
const { orderId: _removed, ...rest } = route.query;
void router.replace({ path: route.path, query: rest });
}
function reapply(order: DepositOrder) {
const query: Record<string, string> = {
orderId: order.id,
@@ -226,11 +242,11 @@ function auditStepCount(order: DepositOrder) {
<template>
<div class="recharge-history-page">
<header class="page-header sub-toolbar">
<div class="page-header">
<button class="back-btn" @click="goBack"></button>
<h2>{{ t('recharge.history_title') }}</h2>
<button class="recharge-btn" @click="goRecharge">+ {{ t('recharge.title') }}</button>
</header>
</div>
<div
class="pull-indicator"
@@ -409,26 +425,23 @@ function auditStepCount(order: DepositOrder) {
</template>
<style scoped>
.recharge-history-page { padding: 0 0 24px; }
.page-header { display: flex; align-items: center; justify-content: space-between; }
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; }
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }
.recharge-btn { background: none; border: none; color: var(--primary-light); font-size: 13px; cursor: pointer; font-weight: 600; }
.recharge-history-page { padding: 0 16px 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; color: var(--text); }
.back-btn { background: none; border: none; color: var(--primary); font-size: 24px; cursor: pointer; padding: 0 8px; }
.recharge-btn { background: none; border: none; color: var(--primary); font-size: 13px; cursor: pointer; font-weight: 600; }
.state { display: flex; justify-content: center; padding: 48px; }
.empty { text-align: center; color: var(--text-muted); padding: 48px 16px; font-weight: 600; }
.pull-indicator { display: flex; align-items: center; justify-content: center; overflow: hidden; transition: height 0.15s ease; }
.order-list { display: flex; flex-direction: column; gap: 12px; }
.order-card {
background: var(--gradient-card);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius);
border-radius: var(--radius-lg);
padding: 16px;
position: relative;
overflow: hidden;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
box-shadow: var(--shadow);
}
.order-card:active {
opacity: 0.92;
@@ -438,14 +451,14 @@ function auditStepCount(order: DepositOrder) {
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
background: linear-gradient(90deg, transparent, #F4A261, transparent);
}
.order-card.rejected {
background: var(--bg-card);
border-color: rgba(255, 255, 255, 0.35);
border-color: rgba(239, 68, 68, 0.3);
}
.order-card.rejected::before {
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.35), transparent);
background: linear-gradient(90deg, transparent, #EF4444, transparent);
}
.order-card.rejected .order-amount {
background: none;
@@ -454,50 +467,50 @@ function auditStepCount(order: DepositOrder) {
color: var(--text-muted);
}
.order-card.rejected .info-label {
color: var(--neutral);
color: var(--text-muted);
}
.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; }
.method-badge.usdt { background: rgba(26, 58, 42, 0.6); color: #67c23a; }
.method-badge.bank { background: rgba(244, 162, 97, 0.15); color: var(--primary-light); }
.method-badge.usdt { background: rgba(16, 185, 129, 0.15); color: var(--success); }
.status-badge { font-size: 12px; font-weight: 700; }
.status-pending { color: var(--primary-light); }
.status-pending { color: var(--warning); }
.status-approved { color: var(--success); }
.status-rejected { color: var(--primary); }
.status-rejected { color: var(--danger); }
.order-body { }
.order-amount {
font-size: 22px;
font-weight: 900;
margin-bottom: 4px;
color: var(--primary-light);
color: var(--primary);
}
.approved-amount { font-size: 12px; color: var(--success); margin-bottom: 6px; font-weight: 600; }
.order-info-row { margin-bottom: 8px; }
.info-label { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.info-label { font-size: 13px; color: var(--primary); font-weight: 600; }
.order-times { display: flex; flex-direction: column; gap: 4px; margin-bottom: 6px; }
.time-row { display: flex; justify-content: space-between; align-items: center; }
.time-label { font-size: 11px; color: var(--text-muted); }
.time-value { font-size: 11px; color: var(--neutral); font-variant-numeric: tabular-nums; }
.time-value { font-size: 11px; color: var(--text); font-variant-numeric: tabular-nums; }
.order-remark {
font-size: 12px;
color: var(--text-muted);
background: var(--surface-accent);
color: var(--primary);
background: rgba(244, 162, 97, 0.08);
padding: 6px 10px;
border-radius: 6px;
margin-top: 6px;
border-left: 2px solid var(--border-gold-soft);
border-left: 2px solid var(--primary);
line-height: 1.45;
word-break: break-word;
}
.reject-reason {
margin-top: 8px;
font-size: 12px;
color: #c07070;
color: var(--danger);
background: transparent;
padding: 6px 0 0;
border-radius: 0;
border: none;
border-top: 1px solid rgba(245, 108, 108, 0.18);
border-top: 1px solid rgba(239, 68, 68, 0.25);
line-height: 1.45;
word-break: break-word;
}
@@ -517,7 +530,7 @@ function auditStepCount(order: DepositOrder) {
}
.card-detail-link {
font-size: 11px;
color: var(--primary-light);
color: var(--primary);
font-weight: 700;
white-space: nowrap;
}
@@ -530,7 +543,7 @@ function auditStepCount(order: DepositOrder) {
background: none;
text-align: right;
font-size: 11px;
color: var(--primary-light);
color: var(--primary);
font-weight: 700;
cursor: pointer;
}
@@ -548,7 +561,7 @@ function auditStepCount(order: DepositOrder) {
.end-hint {
text-align: center;
font-size: 12px;
color: var(--neutral);
color: var(--text-muted);
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;
@@ -558,7 +571,7 @@ function auditStepCount(order: DepositOrder) {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 34, 68, 0.72);
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: flex-end;
justify-content: center;
@@ -571,14 +584,12 @@ function auditStepCount(order: DepositOrder) {
max-width: 480px;
max-height: min(88vh, 720px);
overflow-y: auto;
background: var(--dropdown-bg);
backdrop-filter: none;
-webkit-backdrop-filter: none;
background: var(--bg-card);
border: 1px solid var(--border);
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.35);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.4);
}
.detail-close {
@@ -608,8 +619,6 @@ function auditStepCount(order: DepositOrder) {
padding: 12px;
border-radius: 10px;
background: var(--bg-body);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid var(--border);
}
@@ -624,7 +633,7 @@ function auditStepCount(order: DepositOrder) {
font-size: 22px;
font-weight: 700;
margin-bottom: 4px;
color: var(--primary-light);
color: var(--primary);
letter-spacing: -0.02em;
}
@@ -672,12 +681,12 @@ function auditStepCount(order: DepositOrder) {
.detail-summary .reject-reason {
margin-top: 8px;
font-size: 11px;
color: var(--primary-light);
color: var(--danger);
background: transparent;
padding: 6px 0 0;
border-radius: 0;
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.25);
border-top: 1px solid rgba(239, 68, 68, 0.2);
line-height: 1.45;
word-break: break-word;
}
@@ -725,7 +734,7 @@ function auditStepCount(order: DepositOrder) {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--neutral);
background: var(--border);
flex-shrink: 0;
}
@@ -763,7 +772,7 @@ function auditStepCount(order: DepositOrder) {
.audit-step-time {
font-size: 10px;
color: var(--neutral);
color: var(--text-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
@@ -804,36 +813,36 @@ function auditStepCount(order: DepositOrder) {
}
.audit-step-box--reject {
background: var(--bg-body);
border: 1px solid rgba(255, 255, 255, 0.25);
background: transparent;
border: 1px solid rgba(239, 68, 68, 0.3);
}
.audit-step-box-label {
font-size: 10px;
font-weight: 500;
color: var(--primary-light);
color: var(--danger);
letter-spacing: 0.01em;
}
.audit-step-box-text {
font-size: 11px;
color: var(--text-muted);
color: var(--danger);
}
.audit-step--submitted .audit-dot {
background: var(--primary-light);
background: var(--primary);
}
.audit-step--approved .audit-dot {
background: var(--success);
}
.audit-step--rejected .audit-dot {
background: var(--primary);
background: var(--danger);
}
.audit-step--revoked .audit-dot {
background: var(--neutral);
background: var(--text-muted);
}
.audit-step--reopened .audit-dot {
background: var(--primary-light);
background: var(--primary);
}
.modal-reapply-btn {

View File

@@ -250,11 +250,11 @@ onMounted(fetchMethods);
<template>
<div class="recharge-page">
<header class="page-header sub-toolbar">
<div class="page-header">
<button class="back-btn" @click="goBack"></button>
<h2>{{ t('recharge.title') }}</h2>
<button class="history-btn" @click="goHistory">{{ t('recharge.history') }}</button>
</header>
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
@@ -379,14 +379,15 @@ onMounted(fetchMethods);
</template>
<style scoped>
.recharge-page { padding: 0 0 24px; }
.recharge-page { padding: 0 12px 24px; }
.page-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 0 12px;
}
.page-header h2 { margin: 0; font-size: 16px; font-weight: 700; }
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 22px; cursor: pointer; padding: 0 6px; }
.history-btn { background: none; border: none; color: var(--primary-light); font-size: 12px; cursor: pointer; font-weight: 600; }
.page-header h2 { margin: 0; font-size: 16px; font-weight: 700; color: var(--text); }
.back-btn { background: none; border: none; color: var(--primary); font-size: 22px; cursor: pointer; padding: 0 6px; }
.history-btn { background: none; border: none; color: var(--primary); font-size: 12px; cursor: pointer; font-weight: 600; }
.state { display: flex; justify-content: center; padding: 48px; }
@@ -396,34 +397,24 @@ onMounted(fetchMethods);
border-radius: 8px;
font-size: 12px;
line-height: 1.45;
color: #ffd0d0;
background: rgba(245, 108, 108, 0.12);
border: 1px solid rgba(245, 108, 108, 0.25);
color: var(--danger);
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.type-tabs {
display: flex;
margin-bottom: 12px;
border-radius: var(--radius-sm);
overflow: hidden;
display: flex; margin-bottom: 12px;
border-radius: 6px; overflow: hidden;
border: 1px solid var(--border);
background: var(--bg-body);
}
.tab {
flex: 1;
padding: 8px;
border: none;
flex: 1; padding: 8px; border: none;
background: var(--bg-card);
color: var(--text-muted);
font-weight: 700;
font-size: 13px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
color: var(--text-muted); font-weight: 700; font-size: 13px;
cursor: pointer; transition: all 0.2s;
}
.tab.active {
background: var(--surface-accent);
color: var(--text);
box-shadow: inset 0 0 0 1px var(--primary);
background: var(--primary); color: #FFFFFF;
}
.methods-list {
@@ -438,8 +429,8 @@ onMounted(fetchMethods);
transition: border-color 0.15s;
}
.method-pill.selected {
border-color: var(--primary);
background: var(--surface-accent);
border-color: var(--border-active);
background: rgba(244, 162, 97, 0.1);
}
.pill-name { font-weight: 700; font-size: 12px; color: var(--text); }
.pill-sub { font-size: 10px; color: var(--text-muted); }
@@ -459,15 +450,9 @@ onMounted(fetchMethods);
flex-direction: column;
justify-content: space-between;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 100% 0%, rgba(255, 255, 255, 0.18), transparent 55%),
radial-gradient(ellipse 50% 50% at 0% 100%, var(--surface-accent), transparent 50%),
var(--gradient-card);
border: 1px solid var(--border-gold-soft);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.35),
0 2px 8px var(--surface-accent),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, #0F2027 0%, #F4A261 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: var(--shadow);
}
.bank-card-shine {
@@ -476,9 +461,9 @@ onMounted(fetchMethods);
background: linear-gradient(
115deg,
transparent 30%,
rgba(255, 255, 255, 0.04) 45%,
rgba(255, 255, 255, 0.08) 50%,
rgba(255, 255, 255, 0.04) 55%,
rgba(255, 255, 255, 0.03) 45%,
rgba(255, 255, 255, 0.06) 50%,
rgba(255, 255, 255, 0.03) 55%,
transparent 70%
);
pointer-events: none;
@@ -487,7 +472,7 @@ onMounted(fetchMethods);
.bank-card-deco {
position: absolute;
border-radius: 50%;
border: 1px solid var(--border-gold-soft);
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
@@ -496,7 +481,7 @@ onMounted(fetchMethods);
height: 180px;
right: -60px;
bottom: -80px;
background: radial-gradient(circle, var(--surface-accent), transparent 70%);
background: radial-gradient(circle, rgba(255, 185, 128, 0.15), transparent 70%);
}
.bank-card-deco--2 {
@@ -504,7 +489,7 @@ onMounted(fetchMethods);
height: 100px;
right: 40px;
bottom: 20px;
border-color: rgba(255, 255, 255, 0.25);
border-color: rgba(255, 185, 128, 0.12);
}
.bank-card-top,
@@ -524,10 +509,9 @@ onMounted(fetchMethods);
.bank-card-bank {
font-size: 15px;
font-weight: 800;
color: #fff;
color: #FFFFFF;
text-align: right;
letter-spacing: 0.5px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
line-height: 1.3;
max-width: 58%;
word-break: break-all;
@@ -546,8 +530,7 @@ onMounted(fetchMethods);
font-size: clamp(15px, 4.2vw, 19px);
font-weight: 700;
letter-spacing: 0.14em;
color: var(--primary-light);
text-shadow: 0 0 12px rgba(240, 216, 117, 0.25);
color: #FFFFFF;
word-break: break-all;
line-height: 1.3;
}
@@ -557,9 +540,9 @@ onMounted(fetchMethods);
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--border-gold-soft);
background: rgba(0, 34, 68, 0.45);
color: var(--primary-light);
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.1);
color: #FFFFFF;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -574,7 +557,7 @@ onMounted(fetchMethods);
.bank-card-copy:active {
opacity: 0.65;
background: var(--surface-accent);
background: rgba(255, 255, 255, 0.2);
}
.bank-card-bottom {
@@ -596,15 +579,14 @@ onMounted(fetchMethods);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
color: rgba(255, 255, 255, 0.6);
}
.bank-card-holder-name {
font-size: 14px;
font-weight: 700;
color: #fff;
color: #FFFFFF;
letter-spacing: 0.04em;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
word-break: break-all;
}
@@ -613,21 +595,20 @@ onMounted(fetchMethods);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
color: var(--primary-light);
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.method-info {
background: var(--bg-body);
border-radius: var(--radius-sm);
padding: 10px 12px;
background: var(--bg-card);
border-radius: 8px; padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid var(--border);
}
.info-row {
display: flex; justify-content: space-between; align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
border-bottom: 1px solid var(--border);
}
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
@@ -635,15 +616,16 @@ onMounted(fetchMethods);
font-size: 13px; font-weight: 600;
word-break: break-all; text-align: right;
max-width: 60%;
color: var(--text);
}
.copyable {
cursor: pointer; color: var(--primary-light);
cursor: pointer; color: var(--primary);
display: inline-flex; align-items: center; gap: 4px;
}
.copyable:active { opacity: 0.6; }
.copy-icon { width: 14px; height: 14px; flex-shrink: 0; }
.qr-container { display: flex; justify-content: center; padding: 8px 0 4px; }
.qr-image { width: 140px; height: 140px; object-fit: contain; border-radius: 6px; background: #fff; padding: 6px; }
.qr-image { width: 140px; height: 140px; object-fit: contain; border-radius: 6px; background: #fff; padding: 6px; border: 1px solid var(--border); }
.form-section { margin-bottom: 12px; }
.form-section label {
@@ -656,46 +638,46 @@ onMounted(fetchMethods);
color: var(--text); font-size: 16px; font-weight: 700;
box-sizing: border-box;
}
.amount-input:focus { border-color: var(--border-gold-soft); outline: none; }
.amount-input:focus { border-color: var(--border-active); outline: none; box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.2); }
.upload-area {
border: 1px dashed var(--border-gold-soft);
border: 1px dashed var(--primary);
border-radius: 6px; padding: 16px;
text-align: center; position: relative;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
background: var(--bg-body);
background: rgba(244, 162, 97, 0.06);
}
.upload-area input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.upload-hint { font-size: 12px; color: var(--text-muted); }
.compress-hint { font-size: 12px; color: var(--primary-light); }
.compress-hint { font-size: 12px; color: var(--primary); }
.screenshot-preview { position: relative; display: inline-block; }
.screenshot-preview img { max-width: 100%; max-height: 160px; border-radius: 6px; }
.remove-btn {
position: absolute; top: 4px; right: 4px;
background: rgba(0,0,0,0.7); border: none; color: #fff;
background: rgba(0, 0, 0, 0.6); border: none; color: #fff;
width: 20px; height: 20px; border-radius: 50%;
cursor: pointer; font-size: 11px;
}
.btn-submit {
width: 100%; padding: 12px;
background: var(--gradient-accent);
color: #fff; border: none; border-radius: 6px;
background: var(--primary);
color: #FFFFFF; border: none; border-radius: 6px;
font-size: 14px; font-weight: 800;
cursor: pointer; margin-top: 8px;
box-shadow: none;
box-shadow: var(--shadow-glow);
}
.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
.success-state { text-align: center; padding: 40px 16px; }
.success-icon { font-size: 40px; color: var(--success); margin-bottom: 10px; }
.success-state h3 { margin: 0 0 6px; font-size: 16px; color: var(--text); }
.order-no { font-family: monospace; color: var(--primary-light); font-size: 13px; margin: 4px 0; }
.order-no { font-family: monospace; color: var(--primary); font-size: 13px; margin: 4px 0; }
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
.btn-primary {
background: var(--gradient-accent);
color: #fff; border: none; border-radius: 6px;
background: var(--primary);
color: #FFFFFF; border: none; border-radius: 6px;
padding: 10px 20px; font-weight: 700; font-size: 13px; cursor: pointer;
}
</style>

View File

@@ -155,9 +155,9 @@ const pullIndicatorStyle = () => ({
<template>
<div class="wallet-detail-page">
<header class="top-bar sub-toolbar">
<div class="top-bar">
<button class="back-btn" type="button" @click="router.back()">&#x2039; {{ t('history.back') }}</button>
</header>
</div>
<div
class="pull-indicator"
@@ -172,7 +172,7 @@ const pullIndicatorStyle = () => ({
</div>
<template v-else>
<WalletStatsPanel class="stats-panel" :items="items" :cashback-total="cashbackTotal" />
<WalletStatsPanel :items="items" :cashback-total="cashbackTotal" />
<div class="filter-tabs">
<button
@@ -195,15 +195,17 @@ const pullIndicatorStyle = () => ({
class="tx-row"
@click="goDetail(tx)"
>
<div class="tx-col-left">
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
</div>
<div class="tx-col-right">
<div class="tx-main">
<div class="tx-text">
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
</div>
<span :class="txAmountClass(tx.amount)">
{{ formatMoney(tx.amount, locale) }}
</span>
</div>
<div class="tx-meta">
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
<span class="tx-arrow">&#x203A;</span>
</div>
</button>
@@ -231,10 +233,6 @@ const pullIndicatorStyle = () => ({
padding-bottom: 24px;
}
.stats-panel {
margin-bottom: var(--space-section);
}
.top-bar {
margin-bottom: 4px;
}
@@ -242,9 +240,9 @@ const pullIndicatorStyle = () => ({
.back-btn {
background: none;
border: none;
color: var(--text-muted);
color: var(--primary);
font-size: 15px;
font-weight: 500;
font-weight: 700;
padding: 4px 0 8px;
cursor: pointer;
display: flex;
@@ -262,8 +260,8 @@ const pullIndicatorStyle = () => ({
.filter-tabs {
display: flex;
gap: 8px;
margin-bottom: var(--space-section);
gap: 6px;
margin-bottom: 12px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
@@ -273,21 +271,20 @@ const pullIndicatorStyle = () => ({
.filter-tab {
flex-shrink: 0;
padding: 8px 16px;
padding: 7px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
font-weight: 700;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
transition: background 0.2s, border-color 0.2s, color 0.2s;
transition: all 0.2s;
}
.filter-tab.active {
color: var(--text);
background: var(--bg-elevated);
border-color: var(--border-strong);
font-weight: 600;
color: #FFFFFF;
background: var(--primary);
border-color: var(--primary);
}
.state {
@@ -308,21 +305,14 @@ const pullIndicatorStyle = () => ({
.tx-list {
margin-bottom: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.tx-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
display: block;
width: 100%;
text-align: left;
font-size: 14px;
padding: 12px 14px;
padding: 12px 16px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
@@ -334,22 +324,21 @@ const pullIndicatorStyle = () => ({
}
.tx-row:active {
background: var(--bg-hover);
background: rgba(255, 255, 255, 0.04);
}
.tx-col-left {
.tx-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.tx-text {
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
min-width: 0;
flex: 1;
}
.tx-col-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.tx-summary {
@@ -361,12 +350,19 @@ const pullIndicatorStyle = () => ({
text-overflow: ellipsis;
}
.tx-type { font-weight: 600; color: var(--text); letter-spacing: -0.01em; }
.pos { color: var(--success); font-weight: 600; font-size: 14px; font-variant-numeric: tabular-nums; }
.neg { color: var(--text); font-weight: 600; font-size: 14px; font-variant-numeric: tabular-nums; }
.zero { color: var(--text-muted); font-weight: 600; font-size: 14px; font-variant-numeric: tabular-nums; }
.tx-time { font-size: 11px; color: var(--text-dim); }
.tx-arrow { font-size: 15px; color: var(--text-dim); font-weight: 400; line-height: 1; }
.tx-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
}
.tx-type { font-weight: 700; color: var(--text); }
.pos { color: var(--accent-light); font-weight: 800; font-size: 15px; }
.neg { color: var(--danger); font-weight: 700; }
.zero { color: var(--text-muted); font-weight: 700; font-size: 15px; }
.tx-time { font-size: 11px; color: var(--text-muted); }
.tx-arrow { font-size: 16px; color: var(--text-muted); font-weight: 700; line-height: 1; }
.sentinel {
height: 1px;

View File

@@ -138,9 +138,9 @@ function goCashbackDetail() {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<header class="top-bar sub-toolbar">
<div class="top-bar">
<button class="back-btn" type="button" @click="router.back()"> {{ t('history.back') }}</button>
</header>
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
@@ -235,7 +235,7 @@ function goCashbackDetail() {
.back-btn {
background: none;
border: none;
color: var(--primary-light);
color: var(--primary);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
@@ -254,7 +254,7 @@ function goCashbackDetail() {
}
.hero {
border-radius: var(--radius);
border-radius: 14px;
padding: 20px;
display: flex;
flex-direction: column;
@@ -264,16 +264,17 @@ function goCashbackDetail() {
text-align: center;
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: var(--shadow);
}
.hero.pos {
border-color: rgba(46, 204, 113, 0.35);
background: linear-gradient(135deg, rgba(46, 204, 113, 0.16), rgba(46, 204, 113, 0.05));
border-color: rgba(244, 162, 97, 0.3);
background: linear-gradient(135deg, rgba(244, 162, 97, 0.15), var(--bg-card));
}
.hero.neg {
border-color: rgba(255, 255, 255, 0.35);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.05));
border-color: rgba(239, 68, 68, 0.3);
background: linear-gradient(135deg, rgba(239, 68, 68, 0.15), var(--bg-card));
}
.hero.zero {
@@ -292,7 +293,7 @@ function goCashbackDetail() {
.hero-summary {
font-size: 13px;
font-weight: 600;
color: var(--text);
color: var(--text-muted);
max-width: 100%;
word-break: break-all;
}
@@ -303,22 +304,23 @@ function goCashbackDetail() {
line-height: 1.1;
}
.hero.pos .hero-amount { color: var(--success); }
.hero.neg .hero-amount { color: var(--primary); }
.hero.pos .hero-amount { color: var(--primary); }
.hero.neg .hero-amount { color: var(--danger); }
.hero.zero .hero-amount { color: var(--text-muted); }
.hero-time {
font-size: 11px;
color: var(--neutral);
color: var(--text-muted);
font-weight: 600;
}
.section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-card) 16px;
margin-bottom: var(--space-section);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 10px;
box-shadow: var(--shadow);
}
.section-title {
@@ -351,13 +353,14 @@ function goCashbackDetail() {
word-break: break-all;
}
.pos { color: var(--success) !important; }
.neg { color: var(--primary) !important; }
.pos { color: var(--primary) !important; }
.neg { color: var(--danger) !important; }
.zero { color: var(--text-muted) !important; }
.mono {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--text-secondary);
}
.remark {
@@ -370,9 +373,9 @@ function goCashbackDetail() {
margin-top: 12px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-gold-soft);
background: var(--surface-accent);
color: var(--primary-light);
border: 1px solid var(--primary);
background: rgba(244, 162, 97, 0.1);
color: var(--primary);
font-size: 13px;
font-weight: 700;
cursor: pointer;
@@ -381,7 +384,7 @@ function goCashbackDetail() {
.tx-id {
font-family: ui-monospace, monospace;
font-size: 12px;
color: var(--text-muted);
color: var(--text-secondary);
word-break: break-all;
line-height: 1.5;
}

View File

@@ -1,588 +1,263 @@
<script setup lang="ts">
import { ref, onActivated, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../utils/walletTx';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
const router = useRouter();
const { t, locale } = useI18n();
type Transaction = {
transactionType: string;
displayType?: string;
summary?: string | null;
summaryKind?: 'opening_bonus' | null;
referenceType?: string | null;
amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
transactionId: string;
};
const items = ref<Transaction[]>([]);
const cashbackTotal = ref<string>('0');
const loading = ref(true);
const PREVIEW_COUNT = 15;
function txLabel(tx: Transaction): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
}
return tx.transactionType;
}
function txSubtitle(tx: Transaction): string {
return txSummaryLabel(tx, t);
}
function goDetail(tx: Transaction) {
if (!tx.transactionId) return;
router.push(`/wallet/transactions/${tx.transactionId}`);
}
function goCashbacks() {
router.push('/wallet/cashbacks');
}
async function fetchData() {
loading.value = true;
try {
const { data } = await api.get('/player/wallet/transactions', { params: { page: 1 } });
const result = data.data ?? { items: [] };
const [txRes, cbRes] = await Promise.all([
api.get('/player/wallet/transactions', { params: { page: 1 } }),
api.get('/player/cashbacks').catch(() => ({ data: { data: [] } })),
]);
const result = txRes.data.data ?? { items: [] };
items.value = (result.items ?? []).slice(0, PREVIEW_COUNT);
const cbRows = parseCashbackApiData(cbRes.data?.data);
cashbackTotal.value = sumCashbackAmount(cbRows).toString();
} catch {
/* ignore */
} finally {
loading.value = false;
}
}
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: fetchData,
});
onMounted(fetchData);
onActivated(fetchData);
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
</script>
<template>
<div class="wallet-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
<span class="loading-text">{{ t('bet.loading') }}</span>
</div>
<template v-else>
<div class="wallet-links">
<button type="button" class="wallet-link" @click="goCashbacks">
{{ t('wallet.view_cashbacks') }}
<div class="top-bar">
<button type="button" class="more-link secondary" @click="goCashbacks">
{{ t('wallet.view_cashbacks_detail') }} &#x203A;
</button>
<button
v-if="items.length"
type="button"
class="wallet-link wallet-link--primary"
class="more-link"
@click="router.push('/wallet/detail')"
>
{{ t('wallet.view_all') }}
</button>
>{{ t('wallet.view_all') }} &#x203A;</button>
</div>
<div v-if="items.length" class="tx-list">
<button
v-for="tx in items"
:key="tx.transactionId"
type="button"
class="tx-row"
@click="goDetail(tx)"
>
<div class="tx-col-left">
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
</div>
<div class="tx-col-right">
<div class="tx-main">
<div class="tx-text">
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
</div>
<span :class="txAmountClass(tx.amount)">
{{ formatMoney(tx.amount, locale) }}
</span>
<span class="tx-arrow" aria-hidden="true"></span>
</div>
<div class="tx-meta">
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
<span class="tx-arrow">&#x203A;</span>
</div>
</button>
</div>
<div v-if="!items.length" class="empty">
{{ t('wallet.no_records') }}
</div>
</template>
</div>
</template>
<style scoped>
.wallet-page {
padding-bottom: 24px;
padding-bottom: 16px;
}
.pull-indicator {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: height 0.15s ease;
}
.state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
color: var(--text-muted);
padding: 56px 20px;
font-weight: 500;
font-weight: 600;
font-size: 14px;
}
.loading-text {
font-size: 13px;
}
.wallet-links {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 0 0 12px;
}
.wallet-link {
background: none;
border: none;
padding: 4px 0;
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
cursor: pointer;
}
.wallet-link--primary {
color: var(--text);
font-weight: 600;
}
.wallet-link:active {
opacity: 0.72;
}
.tx-list {
margin-bottom: 0;
background: var(--gradient-card);
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-card-sm);
}
.tx-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
display: block;
width: 100%;
text-align: left;
font-size: 14px;
padding: 14px 16px;
font-size: 13px;
padding: 6px 10px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
cursor: pointer;
}
.tx-row:last-child {
border-bottom: none;
}
.tx-row:active {
background: var(--bg-hover);
background: rgba(255, 255, 255, 0.04);
}
.tx-col-left {
.tx-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.tx-col-right {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
flex-shrink: 0;
gap: 12px;
}
.tx-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.tx-summary {
font-size: 12px;
color: var(--text-dim);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tx-type {
font-weight: 600;
color: var(--text);
letter-spacing: -0.01em;
}
.pos {
color: var(--success);
font-weight: 600;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.neg {
color: var(--text);
font-weight: 600;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.zero {
color: var(--text-muted);
font-weight: 600;
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.tx-time {
font-size: 11px;
color: var(--text-dim);
}
.tx-arrow {
font-size: 15px;
color: var(--text-dim);
font-weight: 400;
line-height: 1;
}
.empty {
text-align: center;
color: var(--text-muted);
padding: 40px 16px;
font-weight: 500;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
.tx-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 3px;
}
.tx-type { font-weight: 700; color: var(--text); }
.pos { color: var(--accent-light); font-weight: 800; font-size: 14px; }
.neg { color: var(--danger); font-weight: 700; }
.zero { color: var(--text-muted); font-weight: 700; font-size: 14px; }
.tx-time { font-size: 11px; color: var(--text-muted); }
.tx-arrow { font-size: 16px; color: var(--text-muted); font-weight: 700; line-height: 1; }
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px 1px;
margin-top: -6px;
}
.top-bar .more-link.secondary {
color: var(--accent);
}
.more-link {
background: none;
border: none;
color: var(--primary);
font-size: 12px;
font-weight: 600;
padding: 2px 6px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
}
.more-link:active {
opacity: 1;
}
.empty { text-align: center; color: var(--text-muted); padding: 28px 14px; font-weight: 600; }
</style>

View File

@@ -1,9 +1,24 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useViewport } from '../composables/useViewport';
import MobileRechargeDetailView from './MobileRechargeDetailView.vue';
import DesktopRechargeDetailView from './desktop/DesktopRechargeDetailView.vue';
const route = useRoute();
const router = useRouter();
const { isDesktop } = useViewport();
onMounted(() => {
if (!isDesktop.value) return;
const id = route.params.id;
if (id) {
void router.replace({
path: '/wallet/recharge/history',
query: { orderId: String(id) },
});
}
});
</script>
<template>

View File

@@ -9,7 +9,7 @@ import { useSmsCode } from '../composables/useSmsCode';
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
import GoldSpinner from '../components/GoldSpinner.vue';
import loginBg from '../assets/images/h5bg.webp';
import loginBg from '../assets/images/h5bg2.webp';
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
@@ -103,156 +103,111 @@ const fieldError = () => {
</script>
<template>
<div
class="login-page"
:class="{ 'login-page--desktop': props.desktop }"
:style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }"
>
<div class="login-page auth-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>
<div class="login-stack">
<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>
</div>
<h2 class="form-title">{{ t('auth.register') }}</h2>
<div class="field">
<label>{{ t('auth.username') }}</label>
<input
v-model="account"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
required
autocomplete="username"
maxlength="32"
minlength="7"
:disabled="loading"
:placeholder="t('auth.username_register_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('auth.phone') }}</label>
<div class="inline-row">
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
type="tel"
required
autocomplete="tel-national"
:disabled="loading"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
</div>
<div class="field">
<label>{{ t('auth.sms_code') }}</label>
<div class="inline-row">
<input
v-model="smsCode"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
:disabled="loading"
:placeholder="t('auth.sms_code_placeholder')"
/>
<button
type="button"
class="btn-secondary"
:disabled="sending || countdown > 0 || !phone.trim() || loading"
@click="sendCode"
>
<span v-if="sending" class="btn-inline-loading">
<GoldSpinner :size="14" :active="true" />
{{ t('auth.sending_sms') }}
</span>
<span v-else>{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}</span>
</button>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<input
v-model="password"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
type="password"
required
autocomplete="new-password"
minlength="8"
:disabled="loading"
/>
</div>
<div class="field">
<label>{{ t('auth.confirm_password') }}</label>
<input
v-model="confirmPassword"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
type="password"
required
autocomplete="new-password"
minlength="8"
:disabled="loading"
/>
</div>
<div class="field field-optional">
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
<input
v-model="inviteCode"
class="field-input"
:class="{ 'ps-gold-input': !props.desktop }"
autocomplete="off"
:disabled="loading"
/>
</div>
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
<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.registering') }}
</span>
<span v-else>{{ t('auth.register_btn') }}</span>
</button>
<button v-if="props.desktop" type="button" class="btn-skip btn-skip--desktop" :disabled="loading" @click="goLogin">
{{ t('auth.have_account') }}
</button>
</form>
<div v-if="!props.desktop" class="login-actions">
<button type="button" class="btn-skip" :disabled="loading" @click="goLogin">
{{ t('auth.have_account') }}
</button>
<button type="button" class="btn-skip-light" :disabled="loading" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
<form @submit.prevent="submit" class="login-form ps-gold-frame auth-card" :class="{ '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>
</div>
</div>
<h2 class="form-title">{{ t('auth.register') }}</h2>
<div class="field">
<label>{{ t('auth.username') }}</label>
<input
v-model="account"
class="field-input"
required
autocomplete="username"
maxlength="32"
minlength="7"
:disabled="loading"
:placeholder="t('auth.username_register_placeholder')"
/>
</div>
<div class="field">
<label>{{ t('auth.phone') }}</label>
<div class="inline-row">
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
type="tel"
required
autocomplete="tel-national"
:disabled="loading"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
</div>
<div class="field">
<label>{{ t('auth.sms_code') }}</label>
<div class="inline-row">
<input
v-model="smsCode"
class="field-input"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
:disabled="loading"
:placeholder="t('auth.sms_code_placeholder')"
/>
<button
type="button"
class="btn-secondary"
:disabled="sending || countdown > 0 || !phone.trim() || loading"
@click="sendCode"
>
<span v-if="sending" class="btn-inline-loading">
<GoldSpinner :size="14" :active="true" />
{{ t('auth.sending_sms') }}
</span>
<span v-else>{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}</span>
</button>
</div>
</div>
<div class="field">
<label>{{ t('auth.password') }}</label>
<input v-model="password" class="field-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
</div>
<div class="field">
<label>{{ t('auth.confirm_password') }}</label>
<input v-model="confirmPassword" class="field-input" type="password" required autocomplete="new-password" minlength="8" :disabled="loading" />
</div>
<div class="field field-optional">
<label>{{ t('auth.invite_code') }} <span class="optional-tag">{{ t('auth.optional') }}</span></label>
<input v-model="inviteCode" class="field-input" autocomplete="off" :disabled="loading" />
</div>
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
<button
type="submit"
class="btn-login btn-primary"
:class="{ 'is-loading': loading }"
:disabled="loading"
:aria-busy="loading"
>
<span v-if="loading" class="btn-inline-loading">
<GoldSpinner :size="18" :active="true" />
{{ t('auth.registering') }}
</span>
<span v-else>{{ t('auth.register_btn') }}</span>
</button>
<button type="button" class="btn-skip" :disabled="loading" @click="goLogin">
{{ t('auth.have_account') }}
</button>
</form>
<button v-if="!props.desktop" type="button" class="btn-skip-light" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
</div>
</template>
@@ -261,7 +216,7 @@ const fieldError = () => {
position: absolute;
top: max(12px, env(safe-area-inset-top));
right: 16px;
z-index: 10;
z-index: 2;
}
.login-page {
@@ -273,74 +228,22 @@ const fieldError = () => {
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 30vh 16px max(20px, env(safe-area-inset-bottom));
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
background-color: var(--bg-body);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
}
.login-page::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0, 34, 68, 0.35) 0%,
rgba(0, 34, 68, 0.78) 52%,
rgba(0, 34, 68, 0.94) 100%
);
pointer-events: none;
z-index: 0;
}
/* ===== 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-page--desktop::before {
display: none;
}
.login-stack {
.login-form {
position: relative;
z-index: 1;
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
gap: 8px;
}
.login-page--desktop .login-stack {
max-width: none;
}
.login-form {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.login-page--desktop .login-form {
max-width: none;
width: 100%;
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
box-shadow: none !important;
padding: 22px 20px;
}
.login-form.is-busy {
@@ -357,8 +260,8 @@ const fieldError = () => {
justify-content: center;
gap: 10px;
border-radius: inherit;
background: rgba(0, 26, 51, 0.82);
backdrop-filter: blur(2px);
background: rgba(22, 45, 54, 0.88);
backdrop-filter: blur(4px);
pointer-events: all;
}
@@ -366,7 +269,7 @@ const fieldError = () => {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--primary-light);
color: var(--primary);
letter-spacing: 0.04em;
}
@@ -377,28 +280,13 @@ const fieldError = () => {
gap: 8px;
}
.login-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0 14px 4px;
}
.form-title {
margin: 0 0 2px;
font-size: 16px;
margin: 0 0 4px;
font-size: 17px;
font-weight: 800;
color: #fff;
text-align: center;
}
.login-page--desktop .form-title {
font-size: 18px;
margin: 0 0 8px;
text-align: left;
color: var(--primary-light);
letter-spacing: 0.04em;
text-align: center;
letter-spacing: 0.02em;
}
.field {
@@ -407,73 +295,36 @@ const fieldError = () => {
gap: 4px;
}
.login-page--desktop .field {
gap: 8px;
}
.field-optional {
opacity: 0.85;
}
label {
font-size: 10px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.03em;
}
.login-page--desktop label {
font-size: 13px;
letter-spacing: 0.03em;
}
.optional-tag {
font-size: 10px;
font-weight: 500;
color: var(--text-dim);
}
.login-page--desktop .optional-tag {
font-size: 12px;
color: var(--text-muted);
}
.field-input {
width: 100%;
height: 36px;
padding: 0 10px;
height: 40px;
padding: 0 12px;
border: 1px solid var(--border);
border-radius: 6px;
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text);
font-size: 14px;
font-family: inherit;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.field-input::placeholder {
color: var(--text-dim);
}
.field-input:focus {
outline: none;
border-color: var(--border-gold-soft);
}
/* 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-strong);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.08);
}
.login-page--desktop .field-input:hover:not(:focus) {
border-color: var(--border-gold-soft);
}
.inline-row {
@@ -493,32 +344,12 @@ label {
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
background: var(--bg-elevated);
color: var(--primary);
font-size: 11px;
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: var(--border-gold-soft);
background: var(--bg-hover);
}
.btn-secondary:not(:disabled).active,
.btn-secondary:active:not(:disabled) {
border-color: var(--border-gold);
background: var(--surface-accent);
color: var(--text);
}
.btn-secondary:disabled {
@@ -532,28 +363,6 @@ 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: var(--surface-accent);
border-color: var(--border-strong);
}
.login-page--desktop .btn-login:active:not(:disabled) {
transform: scale(0.98);
}
.btn-login:disabled {
@@ -561,65 +370,43 @@ label {
cursor: not-allowed;
}
.login-page--desktop .btn-login:disabled {
opacity: 0.55;
}
.btn-login.is-loading:disabled {
opacity: 1;
}
.btn-skip {
padding: 6px 14px;
margin-top: 2px;
padding: 8px 14px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.55);
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: center;
transition: color 0.2s ease;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
}
.btn-skip--desktop {
margin-top: 6px;
padding: 12px 16px;
border-radius: 4px;
border: 1px solid var(--border);
background: var(--bg-input);
font-size: 14px;
transition: border-color 0.2s ease, color 0.2s ease, background-color 0.2s ease;
}
.btn-skip--desktop:hover:not(:disabled) {
border-color: var(--border-gold-soft);
color: var(--text);
background: var(--bg-hover);
}
.btn-skip:disabled {
opacity: 0.45;
cursor: not-allowed;
color: var(--primary-light);
}
.btn-skip-light {
padding: 6px 16px;
position: absolute;
bottom: calc(20px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
z-index: 1;
padding: 8px 16px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.45);
color: var(--text-muted);
font-size: 13px;
font-weight: 300;
font-weight: 400;
cursor: pointer;
white-space: nowrap;
text-align: center;
}
.btn-skip-light:active {
color: rgba(255, 255, 255, 0.65);
color: var(--primary);
}
.error {
@@ -628,30 +415,100 @@ label {
font-size: 12px;
font-weight: 600;
line-height: 1.3;
animation: reg-fade-in 0.25s ease;
}
/* ===== 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-page--desktop .login-form {
max-width: none;
width: 100%;
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.login-page--desktop .field {
gap: 8px;
}
.login-page--desktop .field-optional {
opacity: 0.85;
}
.login-page--desktop label {
font-size: 13px;
letter-spacing: 0.03em;
}
.login-page--desktop .optional-tag {
font-size: 12px;
}
.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(--primary);
box-shadow: 0 0 0 2px rgba(244, 162, 97, 0.18);
}
.login-page--desktop .field-input:hover:not(:focus) {
border-color: rgba(255, 255, 255, 0.18);
}
.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);
}
.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(244, 162, 97, 0.1);
border-color: var(--primary);
}
.login-page--desktop .btn-login:active:not(:disabled) {
transform: scale(0.98);
}
.login-page--desktop .btn-login:disabled {
opacity: 0.55;
}
.login-page--desktop .btn-skip {
margin-top: 6px;
padding: 12px 16px;
border-radius: 4px;
border: 1px solid var(--border);
background: rgba(15, 32, 39, 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);
}
.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);
}
@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;
}
border-left: 2px solid var(--danger, #FF453A);
}
</style>

View File

@@ -1,9 +1,26 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useViewport } from '../composables/useViewport';
import MobileWalletTransactionDetailView from './MobileWalletTransactionDetailView.vue';
import DesktopWalletTransactionDetailView from './desktop/DesktopWalletTransactionDetailView.vue';
const route = useRoute();
const router = useRouter();
const { isDesktop } = useViewport();
onMounted(() => {
if (!isDesktop.value) return;
const transactionId = route.params.transactionId;
if (transactionId) {
void router.replace({
path: '/wallet/detail',
query: { transactionId: String(transactionId) },
});
}
});
</script>
<template>
<DesktopWalletTransactionDetailView v-if="isDesktop" />
<MobileWalletTransactionDetailView v-else />

View File

@@ -135,7 +135,7 @@ onActivated(() => {
padding: 18px 20px;
border: 1px solid var(--border);
border-radius: 10px;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.08) 0%, rgba(10, 42, 68, 0.95) 42%);
background: linear-gradient(135deg, rgba(244, 162, 97, 0.08) 0%, rgba(18, 18, 18, 0.95) 42%);
}
.back-btn {
@@ -147,8 +147,8 @@ onActivated(() => {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #0A2540;
color: var(--primary-light);
background: var(--bg-elevated);
color: var(--gold);
font-size: 13px;
font-weight: 700;
line-height: 1;
@@ -158,7 +158,7 @@ onActivated(() => {
.back-btn:hover {
border-color: var(--border-gold-soft);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
}
.back-icon {
@@ -202,7 +202,7 @@ onActivated(() => {
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border-gold-soft);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
font-size: 12px;
font-weight: 700;
color: var(--primary-light);
@@ -238,7 +238,7 @@ onActivated(() => {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: rgba(10, 42, 68, 0.96);
background: var(--bg-card);
text-align: left;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
@@ -251,7 +251,7 @@ onActivated(() => {
.card-cover {
aspect-ratio: 16 / 7;
background: #001A33;
background: var(--bg-body);
overflow: hidden;
}
@@ -281,11 +281,11 @@ onActivated(() => {
flex-shrink: 0;
padding: 2px 7px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.22);
background: rgba(244, 162, 97, 0.12);
border: 1px solid rgba(244, 162, 97, 0.22);
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
color: var(--gold);
}
.card-date {

View File

@@ -193,7 +193,7 @@ function translateSel(name: string): string {
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; align-self: flex-start; }
.status-won { background: rgba(52,199,89,0.12); color: #4cd964; }
.status-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
.status-pending { background: rgba(255, 255, 255,0.12); color: var(--primary-light); }
.status-pending { background: rgba(244, 162, 97,0.12); color: var(--primary-light); }
.status-push { background: rgba(100,100,100,0.12); color: #aaa; }
.status-default { background: rgba(100,100,100,0.1); color: #888; }

View File

@@ -1,22 +1,50 @@
<script setup lang="ts">
import { computed, ref, onMounted, onActivated, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import Pagination from '../../components/desktop/Pagination.vue';
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
import DesktopCashbackDetailModal from '../../components/desktop/DesktopCashbackDetailModal.vue';
import { formatMoney, formatMoneyCompact } from '../../utils/localeDisplay';
import { parseCashbackApiData, type CashbackRecord } from '../../utils/cashback';
const COL_COUNT = 6;
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
const detailModalVisible = ref(false);
const detailRecord = ref<CashbackRecord | null>(null);
const allItems = ref<CashbackRecord[]>([]);
const loading = ref(true);
const page = ref(1);
const pageSize = ref(20);
function openDetail(record: CashbackRecord) {
detailRecord.value = record;
detailModalVisible.value = true;
}
function openDetailFromQuery() {
const batchNo = route.query.batchNo;
if (typeof batchNo !== 'string' || !batchNo) return;
const match = allItems.value.find((row) => row.batchNo === batchNo);
if (match) {
openDetail(match);
const { batchNo: _removed, ...rest } = route.query;
void router.replace({ path: route.path, query: rest });
}
}
watch(() => route.query.batchNo, openDetailFromQuery);
watch(allItems, openDetailFromQuery);
watch(detailModalVisible, (visible) => {
if (!visible) detailRecord.value = null;
});
const total = computed(() => allItems.value.length);
const items = computed(() => {
@@ -75,8 +103,14 @@ watch(pageSize, () => {
page.value = 1;
});
onMounted(loadData);
onActivated(loadData);
onMounted(async () => {
await loadData();
openDetailFromQuery();
});
onActivated(async () => {
await loadData();
openDetailFromQuery();
});
</script>
<template>
@@ -113,7 +147,12 @@ onActivated(loadData);
</td>
</tr>
<template v-else-if="items.length">
<tr v-for="row in items" :key="row.id" class="hover-row">
<tr
v-for="row in items"
:key="row.id"
class="hover-row clickable-row"
@click="openDetail(row)"
>
<td>{{ formatPeriod(row.periodStart, row.periodEnd) }}</td>
<td>{{ formatMoneyCompact(row.effectiveStake, locale) }}</td>
<td>{{ formatRate(row.rate) }}</td>
@@ -140,6 +179,8 @@ onActivated(loadData);
/>
</div>
</section>
<DesktopCashbackDetailModal v-model:visible="detailModalVisible" :record="detailRecord" />
</div>
</template>
@@ -149,9 +190,9 @@ onActivated(loadData);
align-items: baseline;
gap: 8px;
padding: 8px 14px;
border: 1px solid rgba(255, 255, 255, 0.22);
border: 1px solid rgba(244, 162, 97, 0.22);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
}
.summary-label {

View File

@@ -108,7 +108,7 @@ const leagueGroups = computed(() => {
const filteredOutrightEvents = computed(() => {
if (mainTab.value !== 'outright') return [];
const keyword = searchQuery.value.trim().toLowerCase();
return outrightEvents.value.filter((e) => {
return outrightEvents.value.filter((e: OutrightEvent) => {
if (keyword) {
const haystack = `${e.title} ${e.leagueName || ''}`.toLowerCase();
if (!haystack.includes(keyword)) return false;
@@ -238,7 +238,7 @@ void loadParlayMatches(true);
{{
t('bet.outright_events_summary', {
events: filteredOutrightEvents.length,
teams: filteredOutrightEvents.reduce((n, e) => n + e.selections.length, 0),
teams: filteredOutrightEvents.reduce((n: number, e: OutrightEvent) => n + e.selections.length, 0),
})
}}
</p>
@@ -303,7 +303,7 @@ void loadParlayMatches(true);
.main-tab.active {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
border: 1px solid var(--border-gold-soft);
}
@@ -347,7 +347,7 @@ void loadParlayMatches(true);
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
background: rgba(255, 255, 255, 0.06);
background: rgba(244, 162, 97, 0.06);
border-left: 3px solid var(--primary);
border-radius: 0 4px 4px 0;
}

View File

@@ -252,7 +252,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
:line-value="m.lineValue"
:selections="m.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(featuredMatch!, m, selId, ev)"
@pick="(selId, event) => handleOddsClick(featuredMatch!, m, selId, event)"
/>
</div>
<div v-if="!getAvailableMarketsForMatch(featuredMatch).length" class="no-market-odds">
@@ -351,7 +351,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
:line-value="getActiveMarket(match)?.lineValue"
:selections="getActiveMarket(match)?.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(match, getActiveMarket(match)!, selId, ev)"
@pick="(selId, event) => handleOddsClick(match, getActiveMarket(match)!, selId, event)"
/>
</template>
<div v-else class="no-market-odds">
@@ -463,7 +463,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
:line-value="getActiveMarket(match)?.lineValue"
:selections="getActiveMarket(match)?.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(match, getActiveMarket(match)!, selId, ev)"
@pick="(selId, event) => handleOddsClick(match, getActiveMarket(match)!, selId, event)"
/>
</template>
<div v-else class="no-market-odds">
@@ -584,14 +584,14 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
border-radius: 8px;
overflow: hidden;
background: linear-gradient(90deg,
rgba(255, 255, 255, 0.02),
rgba(255, 255, 255, 0.14),
rgba(255, 255, 255, 0.02));
rgba(244, 162, 97, 0.02),
rgba(244, 162, 97, 0.14),
rgba(244, 162, 97, 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 */
box-shadow: inset 0 0 12px rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 12px rgba(244, 162, 97, 0.08);
}
/* force the embedded button to fill the strip completely */
@@ -621,7 +621,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
}
.content-section {
background: var(--gradient-card);
background: var(--bg-card); backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 12px;
padding: 16px 18px;
@@ -640,7 +640,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
height: 18px;
border-radius: 2px;
background: linear-gradient(180deg, var(--primary), var(--primary-light));
box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
box-shadow: 0 0 8px rgba(244, 162, 97, 0.6);
}
.st-text {
@@ -693,10 +693,10 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
padding: 18px 20px;
border-radius: 12px;
background:
linear-gradient(135deg, rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.04) 50%, rgba(0, 26, 51, 0.2)),
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;
border: 1px solid var(--border-gold);
box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.04);
box-shadow: var(--shadow-gold), inset 0 0 20px rgba(244, 162, 97, 0.06);
margin-bottom: 14px;
cursor: pointer;
transition: box-shadow 0.25s, transform 0.15s;
@@ -709,13 +709,13 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
position: absolute;
top: 0; right: 0;
width: 140px; height: 140px;
background: radial-gradient(circle at top right, rgba(255, 255, 255, 0.18), transparent 65%);
background: radial-gradient(circle at top right, rgba(244, 162, 97, 0.18), transparent 65%);
pointer-events: none;
}
.featured-match:hover {
transform: translateY(-1px);
box-shadow: 0 4px 24px rgba(255, 255, 255, 0.08), inset 0 0 20px rgba(255, 255, 255, 0.04);
box-shadow: 0 0 24px rgba(244, 162, 97, 0.25), inset 0 0 20px rgba(244, 162, 97, 0.08);
}
.featured-top {
@@ -781,7 +781,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
font-weight: 900;
font-style: italic;
color: var(--primary-light);
text-shadow: 0 0 12px rgba(255, 255, 255, 0.5);
text-shadow: 0 0 12px rgba(244, 162, 97, 0.5);
}
.featured-markets {
@@ -790,8 +790,8 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
gap: 10px;
margin: 14px -20px -18px;
padding: 14px 20px 18px;
background: rgba(0, 26, 51, 0.6);
border-top: 1px solid var(--border);
background: var(--bg-body);
border-top: 1px solid rgba(244, 162, 97, 0.2);
}
.featured-market-block {
@@ -828,8 +828,8 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
}
.match-card:is(:hover, .match-card--engaged) {
border-color: var(--border-strong);
box-shadow: var(--shadow-sm);
border-color: var(--border-gold);
box-shadow: var(--shadow-gold);
transform: translateY(-1px);
}
@@ -1008,7 +1008,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
}
.side-card {
background: var(--gradient-card);
background: var(--bg-card); backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
@@ -1220,7 +1220,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
transform: translateY(8px) scale(0.95);
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);
pointer-events: none;
background: var(--glass);
background: rgba(14, 14, 14, 0.96);
border-radius: 8px 0 0 8px;
}
@@ -1283,8 +1283,8 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
transform: scale(2.8);
opacity: 0;
text-shadow:
0 0 10px rgba(255, 255, 255, 0.8),
0 0 20px rgba(255, 255, 255, 0.4);
0 0 10px rgba(244, 162, 97, 0.8),
0 0 20px rgba(244, 162, 97, 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;
}
@@ -1297,11 +1297,11 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
@keyframes vs-clash-impact {
0% {
text-shadow: 0 0 25px rgba(255, 255, 255, 1);
text-shadow: 0 0 25px rgba(244, 162, 97, 1);
transform: scale(1.25);
}
100% {
text-shadow: 0 0 8px rgba(255, 255, 255, 0.7);
text-shadow: 0 0 8px rgba(244, 162, 97, 0.7);
transform: scale(1);
}
}
@@ -1313,7 +1313,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
.side-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(255, 255, 255, 0.12);
box-shadow: 0 4px 20px rgba(244, 162, 97, 0.12);
}
/* --- 8. 推荐列表 item 左侧金点指示 hover --- */
@@ -1323,7 +1323,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
}
.side-recommend-item:hover {
background: rgba(255, 255, 255, 0.06);
background: rgba(244, 162, 97, 0.06);
transform: translateX(3px);
}
@@ -1346,11 +1346,11 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
right: calc(100% + 6px);
transform: translateY(-50%) translateX(8px);
width: 260px;
background: var(--dropdown-bg);
background: rgba(12, 12, 12, 0.97);
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(255, 255, 255, 0.08);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6), 0 0 20px rgba(244, 162, 97, 0.12);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.25, 0.8, 0.25, 1);
@@ -1439,7 +1439,7 @@ function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: strin
font-weight: 900;
font-style: italic;
color: var(--primary-light);
text-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
text-shadow: 0 0 8px rgba(244, 162, 97, 0.6);
flex-shrink: 0;
opacity: 0;
transform: scale(2.2);

View File

@@ -185,7 +185,7 @@ onActivated(refreshUnreadCount);
.hub-tab.active {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
}
.hub-tab.active .dot { background: var(--primary); box-shadow: 0 0 8px var(--primary); }
@@ -194,7 +194,7 @@ onActivated(refreshUnreadCount);
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
background: var(--tertiary);
flex-shrink: 0;
transition: all 0.2s;
}
@@ -260,6 +260,5 @@ onActivated(refreshUnreadCount);
.hub-panel {
flex: 1;
overflow-y: auto;
padding: 0 24px;
}
</style>

View File

@@ -350,7 +350,7 @@ function getHoveredSide() {
@mouseleave="scheduleClose()"
>
<div class="team-side home">
<TeamEmblem size="xl" :team-code="match.homeTeamCode" :team-name="match.homeTeamName" :logo-url="match.homeTeamLogoUrl" />
<TeamEmblem size="lg" :team-code="match.homeTeamCode" :team-name="match.homeTeamName" :logo-url="match.homeTeamLogoUrl" />
<span class="name">{{ match.homeTeamName }}</span>
</div>
@@ -365,7 +365,7 @@ function getHoveredSide() {
</div>
<div class="team-side away">
<TeamEmblem size="xl" :team-code="match.awayTeamCode" :team-name="match.awayTeamName" :logo-url="match.awayTeamLogoUrl" />
<TeamEmblem size="lg" :team-code="match.awayTeamCode" :team-name="match.awayTeamName" :logo-url="match.awayTeamLogoUrl" />
<span class="name">{{ match.awayTeamName }}</span>
</div>
</div>
@@ -427,7 +427,7 @@ function getHoveredSide() {
:line-value="market.lineValue"
:selections="market.selections"
:is-selected="isSelected"
@pick="(id, ev) => toggleSelection(market.selections.find(s => s.id === id)!, market, ev)"
@pick="(id, event) => toggleSelection(market.selections.find(s => s.id === id)!, market, event)"
/>
</div>
</div>
@@ -455,7 +455,7 @@ function getHoveredSide() {
:selections="market.selections"
:locked="isMarketLocked(market)"
:is-selected="isSelected"
@pick="(id, ev) => toggleSelection(market.selections.find(s => s.id === id)!, market, ev)"
@pick="(id, event) => toggleSelection(market.selections.find(s => s.id === id)!, market, event)"
/>
</div>
</div>
@@ -490,7 +490,7 @@ function getHoveredSide() {
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: var(--desktop-sidebar-panel-bg);
background: var(--bg-body);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -541,7 +541,7 @@ function getHoveredSide() {
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;
border: 1px solid var(--border-gold);
box-shadow: var(--shadow-gold), inset 0 0 20px rgba(255, 255, 255, 0.06);
box-shadow: var(--shadow-gold), inset 0 0 20px rgba(244, 162, 97, 0.06);
border-radius: 12px;
padding: 18px 24px;
overflow: hidden;
@@ -586,7 +586,7 @@ function getHoveredSide() {
font-size: 32px;
font-weight: 900;
color: var(--primary-light);
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
text-shadow: 0 0 10px rgba(244, 162, 97, 0.3);
}
.versus-row .divider {
@@ -606,7 +606,7 @@ function getHoveredSide() {
}
.result-stats-card {
background: rgba(12, 40, 66, 0.8);
background: var(--bg-card); backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
@@ -627,7 +627,7 @@ function getHoveredSide() {
}
.stat-pill {
background: #0A2540;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
@@ -646,7 +646,7 @@ function getHoveredSide() {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(10, 42, 68, 0.85);
background: var(--bg-card);
min-width: 0;
}
@@ -696,9 +696,9 @@ function getHoveredSide() {
.market-filter-chip.active {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.1);
background: rgba(244, 162, 97, 0.1);
border-color: var(--border-gold-soft);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(244, 162, 97, 0.08);
}
.markets-score-section {
@@ -738,7 +738,7 @@ function getHoveredSide() {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(10, 42, 68, 0.98);
background: var(--bg-card);
align-self: start;
}
@@ -752,7 +752,7 @@ function getHoveredSide() {
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #0A2540;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
}
@@ -779,7 +779,7 @@ function getHoveredSide() {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(10, 42, 68, 0.98);
background: var(--bg-card);
align-self: start;
}
@@ -793,7 +793,7 @@ function getHoveredSide() {
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #0A2540;
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
font-size: 11px;
font-weight: 800;

View File

@@ -128,7 +128,7 @@ onActivated(refreshUnreadCount);
.hub-tab.active {
color: var(--primary-light);
background: rgba(255, 255, 255, 0.08);
background: rgba(244, 162, 97, 0.08);
}
.hub-tab.active .dot {
@@ -140,7 +140,7 @@ onActivated(refreshUnreadCount);
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
background: var(--tertiary);
flex-shrink: 0;
}

View File

@@ -1,18 +1,42 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import Pagination from '../../components/desktop/Pagination.vue';
import DesktopBetDetailModal from '../../components/desktop/DesktopBetDetailModal.vue';
import { type BetHistoryItem } from '../../components/BetHistoryCard.vue';
import { useOnLocaleChange } from '../../composables/useOnLocaleChange';
const COL_COUNT = 8;
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const detailModalVisible = ref(false);
const detailBetNo = ref<string | null>(null);
function openDetail(betNo: string) {
detailBetNo.value = betNo;
detailModalVisible.value = true;
}
function openDetailFromQuery() {
const betNo = route.query.betNo;
if (typeof betNo === 'string' && betNo) {
openDetail(betNo);
const { betNo: _removed, ...rest } = route.query;
void router.replace({ path: route.path, query: rest });
}
}
watch(() => route.query.betNo, openDetailFromQuery);
watch(detailModalVisible, (visible) => {
if (!visible) detailBetNo.value = null;
});
const items = ref<BetHistoryItem[]>([]);
const total = ref(0);
const page = ref(1);
@@ -62,7 +86,10 @@ useOnLocaleChange(() => {
});
onActivated(() => { void loadPage(page.value); });
onMounted(() => { void loadPage(1); });
onMounted(() => {
void loadPage(1);
openDetailFromQuery();
});
function statusClass(status: string) {
const map: Record<string, string> = {
@@ -154,7 +181,7 @@ function statusLabel(status: string) {
v-for="bet in items"
:key="bet.betNo"
class="hover-row clickable-row"
@click="router.push(`/bets/${bet.betNo}`)"
@click="openDetail(bet.betNo)"
>
<td class="id-cell">{{ bet.betNo }}</td>
<td>{{ bet.matchTitle || '-' }}</td>
@@ -187,6 +214,8 @@ function statusLabel(status: string) {
/>
</div>
</section>
<DesktopBetDetailModal v-model:visible="detailModalVisible" :bet-no="detailBetNo" />
</div>
</template>

View File

@@ -27,7 +27,7 @@ const activePick = ref<OutrightPick | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
const event = computed<OutrightEvent | null>(() =>
events.value.find((e) => e.id === String(route.params.id)) ?? null,
events.value.find((e: OutrightEvent) => e.id === String(route.params.id)) ?? null,
);
const headTitle = computed(() => {
@@ -182,7 +182,7 @@ onUnmounted(stopPolling);
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: #0c0c0c;
background: var(--bg-body);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -237,7 +237,7 @@ onUnmounted(stopPolling);
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(10, 42, 68, 0.98);
background: rgba(18, 18, 18, 0.98);
overflow: hidden;
}
@@ -274,7 +274,7 @@ onUnmounted(stopPolling);
.settled-tag {
font-size: 10px;
font-weight: 800;
color: #c9a227;
color: var(--primary);
border: 1px solid rgba(201, 162, 39, 0.45);
border-radius: 999px;
padding: 1px 7px;
@@ -298,7 +298,7 @@ onUnmounted(stopPolling);
margin: 8px 0 0;
font-size: 11px;
font-weight: 600;
color: #c9a227;
color: var(--primary);
line-height: 1.45;
}

View File

@@ -309,7 +309,7 @@ async function onAvatarSelect(key: string | null) {
width: 64px;
height: 64px;
border-radius: 50%;
background: #0A2540;
background: var(--secondary);
display: flex;
align-items: center;
justify-content: center;
@@ -347,7 +347,7 @@ async function onAvatarSelect(key: string | null) {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--desktop-border);
background: #001A33;
background: var(--bg-glass); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
color: var(--text);
font-size: 14px;
font-weight: 600;
@@ -371,7 +371,7 @@ async function onAvatarSelect(key: string | null) {
padding: 9px 24px;
border-radius: 6px;
background: var(--primary);
color: #001A33;
color: var(--bg-body);
font-size: 13px;
font-weight: 800;
border: none;

View File

@@ -276,7 +276,7 @@ const balanceDisplay = computed(() =>
.bank-card-type {
font-size: 10px;
font-weight: 800;
color: rgba(255, 255, 255, 0.8);
color: rgba(244, 162, 97, 0.8);
font-style: italic;
}
@@ -286,8 +286,8 @@ const balanceDisplay = computed(() =>
gap: 4px;
padding: 8px 16px;
border-radius: 14px;
background: rgba(0, 26, 51, 0.45);
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(244, 162, 97, 0.35);
color: var(--primary-light);
font-size: 13px;
font-weight: 600;
@@ -296,7 +296,7 @@ const balanceDisplay = computed(() =>
transition: background 0.15s ease;
}
.recharge-btn:hover { background: rgba(255, 255, 255, 0.15); }
.recharge-btn:hover { background: rgba(244, 162, 97, 0.15); }
.bank-card-balance {
flex: 1;
@@ -396,7 +396,7 @@ const balanceDisplay = computed(() =>
transition: background 0.2s;
}
.edit-btn:hover { background: rgba(255, 255, 255,0.08); }
.edit-btn:hover { background: rgba(244, 162, 97,0.08); }
.lang-group { display: flex; gap: 8px; flex-wrap: wrap; }
@@ -418,7 +418,7 @@ const balanceDisplay = computed(() =>
.lang-opt.active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(255, 255, 255,0.08);
background: rgba(244, 162, 97,0.08);
}
.logout-btn {

View File

@@ -311,7 +311,7 @@ function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; align-self: flex-start; }
.status-won { background: rgba(52,199,89,0.12); color: #4cd964; }
.status-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
.status-pending { background: rgba(255, 255, 255,0.12); color: var(--primary-light); }
.status-pending { background: rgba(244, 162, 97,0.12); color: var(--primary-light); }
.status-default { background: rgba(100,100,100,0.1); color: #888; }
.amount-row { display: flex; gap: 32px; padding-top: 4px; flex-wrap: wrap; }
@@ -379,7 +379,7 @@ function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
width: 6px;
height: 6px;
border-radius: 50%;
background: #555;
background: var(--text-muted);
flex-shrink: 0;
}
@@ -425,5 +425,5 @@ function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
.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; }
.audit-step--reopened .audit-dot { background: var(--primary); }
</style>

View File

@@ -1,28 +1,49 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { RouterLink, useRouter } from 'vue-router';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import Pagination from '../../components/desktop/Pagination.vue';
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
import DesktopRechargeDetailModal from '../../components/desktop/DesktopRechargeDetailModal.vue';
import { formatMoney } from '../../utils/localeDisplay';
import { enrichDepositOrder, findDepositOrderById, type PlayerDepositOrder } from '../../utils/depositOrderLookup';
const COL_COUNT = 5;
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
type RechargeOrder = {
id: string;
orderNo: string;
amount: string;
status: string;
methodType?: string;
bankName?: string | null;
paymentMethodName?: string | null;
createdAt: string;
};
const detailModalVisible = ref(false);
const detailOrder = ref<PlayerDepositOrder | null>(null);
async function openDetail(order: PlayerDepositOrder) {
detailOrder.value = await enrichDepositOrder(order);
detailModalVisible.value = true;
}
async function openDetailFromQuery() {
const orderId = route.query.orderId;
if (typeof orderId !== 'string' || !orderId) return;
const existing = items.value.find((order) => order.id === orderId);
const order = existing ?? await findDepositOrderById(orderId);
if (order) await openDetail(order);
const { orderId: _removed, ...rest } = route.query;
void router.replace({ path: route.path, query: rest });
}
watch(() => route.query.orderId, () => {
void openDetailFromQuery();
});
watch(detailModalVisible, (visible) => {
if (!visible) detailOrder.value = null;
});
type RechargeOrder = PlayerDepositOrder;
const items = ref<RechargeOrder[]>([]);
const loading = ref(true);
@@ -46,8 +67,14 @@ async function loadPage(p: number) {
}
}
onMounted(() => loadPage(1));
onActivated(() => loadPage(page.value));
onMounted(async () => {
await loadPage(1);
await openDetailFromQuery();
});
onActivated(async () => {
await loadPage(page.value);
await openDetailFromQuery();
});
function methodLabel(order: RechargeOrder) {
if (order.methodType === 'USDT') return 'USDT';
@@ -109,7 +136,7 @@ function statusLabel(status: string) {
v-for="order in items"
:key="order.id"
class="hover-row clickable-row"
@click="router.push(`/wallet/recharge/history/${order.id}`)"
@click="openDetail(order)"
>
<td class="id-cell">{{ order.orderNo || order.id }}</td>
<td>{{ methodLabel(order) }}</td>
@@ -139,5 +166,7 @@ function statusLabel(status: string) {
/>
</div>
</section>
<DesktopRechargeDetailModal v-model:visible="detailModalVisible" :order="detailOrder" />
</div>
</template>

View File

@@ -158,8 +158,8 @@ onActivated(fetchMethods);
<div v-else-if="success" class="success-card">
<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="rgba(255, 255, 255, 0.92)" stroke-width="3" />
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="rgba(255, 255, 255, 0.92)" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
<circle class="check-circle" cx="26" cy="26" r="24" fill="none" stroke="#F4A261" stroke-width="3" />
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="#F4A261" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</div>
<h2>{{ t('recharge.success_title') || '充值申请已提交' }}</h2>
@@ -403,7 +403,7 @@ onActivated(fetchMethods);
background: rgba(25, 25, 25, 0.65);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid rgba(244, 162, 97, 0.12);
border-radius: 16px;
text-align: center;
max-width: 480px;
@@ -434,7 +434,7 @@ onActivated(fetchMethods);
}
.success-details {
background: rgba(0, 26, 51, 0.25);
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
width: 100%;
@@ -479,8 +479,8 @@ onActivated(fetchMethods);
.action-btn {
padding: 10px 28px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: #3d2800;
background: var(--gradient-primary);
color: #ffffff;
font-size: 13px;
font-weight: 800;
border: none;
@@ -488,12 +488,12 @@ onActivated(fetchMethods);
transition: opacity 0.2s;
text-decoration: none;
display: inline-block;
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 12px rgba(244, 162, 97, 0.15);
}
.action-btn:hover {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 16px rgba(244, 162, 97, 0.3);
}
/* Recharge Box Card */
@@ -501,7 +501,7 @@ onActivated(fetchMethods);
background: rgba(25, 25, 25, 0.65);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid rgba(244, 162, 97, 0.12);
border-radius: 16px;
padding: 24px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
@@ -510,7 +510,7 @@ onActivated(fetchMethods);
/* Segment Tabs */
.type-tabs {
display: flex;
background: rgba(0, 26, 51, 0.35);
background: rgba(0, 0, 0, 0.35);
border-radius: 8px;
padding: 4px;
gap: 4px;
@@ -537,9 +537,9 @@ onActivated(fetchMethods);
}
.type-tab.active {
color: #3d2800;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.2);
color: #ffffff;
background: var(--gradient-primary);
box-shadow: 0 2px 8px rgba(244, 162, 97, 0.2);
}
/* Columns */
@@ -585,13 +585,13 @@ onActivated(fetchMethods);
}
.method-card:hover {
border-color: rgba(255, 255, 255, 0.3);
border-color: rgba(244, 162, 97, 0.3);
background: rgba(255, 255, 255, 0.04);
}
.method-card.selected {
border-color: var(--border-gold-soft);
background: rgba(255, 255, 255, 0.06);
background: rgba(244, 162, 97, 0.06);
}
.method-card-header {
@@ -604,7 +604,7 @@ onActivated(fetchMethods);
width: 24px;
height: 24px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.1);
background: rgba(244, 162, 97, 0.1);
color: var(--primary-light);
display: flex;
align-items: center;
@@ -619,7 +619,7 @@ onActivated(fetchMethods);
.method-card.selected .method-icon-wrap {
background: var(--primary);
color: #3d2800;
color: #ffffff;
}
.method-name {
@@ -645,7 +645,7 @@ onActivated(fetchMethods);
}
.bank-detail-card {
background: rgba(0, 26, 51, 0.2);
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 16px 18px;
@@ -678,8 +678,8 @@ onActivated(fetchMethods);
}
.copy-btn {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(244, 162, 97, 0.1);
border: 1px solid rgba(244, 162, 97, 0.25);
border-radius: 4px;
color: var(--primary-light);
font-size: 10px;
@@ -691,7 +691,7 @@ onActivated(fetchMethods);
}
.copy-btn:hover {
background: rgba(255, 255, 255, 0.2);
background: rgba(244, 162, 97, 0.2);
color: #fff;
}
@@ -775,7 +775,7 @@ onActivated(fetchMethods);
padding: 10px 14px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 26, 51, 0.25);
background: rgba(0, 0, 0, 0.25);
color: var(--text);
font-size: 14px;
font-weight: 700;
@@ -807,9 +807,9 @@ onActivated(fetchMethods);
}
.preset-btn:hover {
border-color: rgba(255, 255, 255, 0.3);
border-color: rgba(244, 162, 97, 0.3);
color: var(--primary-light);
background: rgba(255, 255, 255, 0.05);
background: rgba(244, 162, 97, 0.05);
}
.preview-wrap {
@@ -833,7 +833,7 @@ onActivated(fetchMethods);
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
background: var(--tertiary);
border: none;
color: #fff;
font-size: 12px;
@@ -851,7 +851,7 @@ onActivated(fetchMethods);
gap: 8px;
width: 180px;
height: 120px;
border: 1.5px dashed rgba(255, 255, 255, 0.3);
border: 1.5px dashed rgba(244, 162, 97, 0.3);
background: rgba(255, 255, 255, 0.01);
border-radius: 8px;
cursor: pointer;
@@ -860,7 +860,7 @@ onActivated(fetchMethods);
.upload-zone:hover {
border-color: var(--primary);
background: rgba(255, 255, 255, 0.04);
background: rgba(244, 162, 97, 0.04);
}
.hidden-input {
@@ -880,20 +880,20 @@ onActivated(fetchMethods);
gap: 8px;
padding: 12px 36px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: #3d2800;
background: var(--gradient-primary);
color: #ffffff;
font-size: 14px;
font-weight: 800;
border: none;
cursor: pointer;
transition: all 0.25s ease;
align-self: flex-start;
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15);
box-shadow: 0 4px 12px rgba(244, 162, 97, 0.15);
}
.submit-btn:hover:not(:disabled) {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 16px rgba(244, 162, 97, 0.3);
}
.submit-btn:disabled {

View File

@@ -1,15 +1,39 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import DesktopTxDetailModal from '../../components/desktop/DesktopTxDetailModal.vue';
import { formatMoney } from '../../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
const detailModalVisible = ref(false);
const detailTxId = ref<string | null>(null);
function openDetail(txId: string) {
detailTxId.value = txId;
detailModalVisible.value = true;
}
function openDetailFromQuery() {
const transactionId = route.query.transactionId;
if (typeof transactionId === 'string' && transactionId) {
openDetail(transactionId);
const { transactionId: _removed, ...rest } = route.query;
void router.replace({ path: route.path, query: rest });
}
}
watch(() => route.query.transactionId, openDetailFromQuery);
watch(detailModalVisible, (visible) => {
if (!visible) detailTxId.value = null;
});
function goBack() {
if (window.history.state && window.history.state.back) {
router.back();
@@ -59,8 +83,14 @@ async function loadPage(p: number) {
}
}
onMounted(() => loadPage(1));
onActivated(() => loadPage(1));
onMounted(() => {
void loadPage(1);
openDetailFromQuery();
});
onActivated(() => {
void loadPage(1);
openDetailFromQuery();
});
</script>
<template>
@@ -94,7 +124,7 @@ onActivated(() => loadPage(1));
v-for="tx in items"
:key="tx.transactionId"
class="hover-row clickable-row"
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
@click="openDetail(tx.transactionId)"
>
<td class="type-cell">{{ txLabel(tx) }}</td>
<td class="note-cell">{{ txSummaryLabel(tx, t) || '-' }}</td>
@@ -118,6 +148,11 @@ onActivated(() => loadPage(1));
</template>
</div>
</main>
<DesktopTxDetailModal
v-model:visible="detailModalVisible"
:transaction-id="detailTxId"
/>
</div>
</template>
@@ -146,10 +181,10 @@ onActivated(() => loadPage(1));
flex-direction: column;
flex: 0 1 auto;
min-height: 0;
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
background: var(--desktop-surface-solid);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
overflow: hidden;
}
.table-wrap { flex: 1; overflow-x: auto; overflow-y: auto; }
@@ -163,7 +198,7 @@ onActivated(() => loadPage(1));
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
background: #0A2540;
background: var(--desktop-surface-header);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--desktop-border);

View File

@@ -1,17 +1,25 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, RouterLink } from 'vue-router';
import { RouterLink } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import Pagination from '../../components/desktop/Pagination.vue';
import DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
import DesktopTxDetailModal from '../../components/desktop/DesktopTxDetailModal.vue';
import { formatMoney } from '../../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
const { t, locale } = useI18n();
const router = useRouter();
const detailModalVisible = ref(false);
const detailTxId = ref<string | null>(null);
function openDetail(txId: string) {
detailTxId.value = txId;
detailModalVisible.value = true;
}
type Transaction = {
transactionType: string;
@@ -113,7 +121,7 @@ onActivated(() => fetchData(1));
v-for="tx in items"
:key="tx.transactionId"
class="hover-row clickable-row"
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
@click="openDetail(tx.transactionId)"
>
<td class="type-cell">{{ txLabel(tx) }}</td>
<td class="muted-cell">{{ txSubtitle(tx) || '-' }}</td>
@@ -139,6 +147,11 @@ onActivated(() => fetchData(1));
<span>{{ t('wallet.no_records') }}</span>
</div>
</section>
<DesktopTxDetailModal
v-model:visible="detailModalVisible"
:transaction-id="detailTxId"
/>
</div>
</template>

View File

@@ -1,7 +1,7 @@
# 四套主题 Docker 生产部署任务
> **目标**:在同一台服务器、**同一套 Docker Compose 栈**内,同时运行 4 套玩家端主题4 个 player 容器 + 1 套共用 api/admin/db
> **适用分支**`main`(暗金)、`theme-2`Pinnacle 蓝白)、`theme-3`统一移动端视觉)、`theme-4`(海军蓝暗色极简)。
> **适用分支**`main`(暗金)、`theme-2`Pinnacle 蓝白)、`theme-3`Dusk Coral 移动 + PC双端)、`theme-4`(海军蓝暗色极简)。
> **关联文档**[Docker部署指南.md](./Docker部署指南.md)、[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)、[AGENTS.md](../AGENTS.md)「主题分支」章节。
---
@@ -475,7 +475,7 @@ location / {
"themes": [
{ "id": "main", "label": "暗金主站", "baseUrl": "https://www.example.com", "enabled": true, "isDefault": true },
{ "id": "theme-2", "label": "Pinnacle 蓝白", "baseUrl": "https://theme2.example.com", "enabled": true, "isDefault": false },
{ "id": "theme-3", "label": "统一移动端", "baseUrl": "https://theme3.example.com", "enabled": true, "isDefault": false },
{ "id": "theme-3", "label": "Dusk Coral 移动 + PC双端", "baseUrl": "https://theme3.example.com", "enabled": true, "isDefault": false },
{ "id": "theme-4", "label": "海军蓝极简", "baseUrl": "https://theme4.example.com", "enabled": true, "isDefault": false }
]
}

View File

@@ -0,0 +1,156 @@
## 玩家端缺失功能分析
> 基于 PRD v1.2 要求 + 行业通用标准,对比当前代码实际实现情况
> 分析日期2026-06-17
---
### 一、PRD 明确要求但尚未完整实现的功能
#### 1. 首页「今日赛事」板块
PRD 4.4 节明确要求首页同时展示「热门赛事」和「今日赛事」两个板块。当前 HomeView.vue 只渲染了 `hotMatches`,缺少按当天开赛时间筛选的「今日赛事」列表。后端 `/player/home` 接口实际已返回 `todayMatches` 数据,前端未消费。
#### 2. 公告详情页
PRD 17.2 节将公告定义为"可点击查看详情的完整内容",与走马灯(短文案滚动)是两种不同的内容形态。当前只有 AnnouncementMarquee 走马灯组件,点击后没有跳转到公告详情页查看完整正文。
#### 3. 投注规则独立页面
PRD 3.3 节要求"投注规则说明必须三语,减少争议"。当前规则内容硬编码在 ProfileView.vue 的 5 段 i18n 翻译文本中rules_p1 ~ rules_p5不可由后台动态维护也没有独立的规则页面。建议做成后台可编辑、前台可独立访问的规则中心页面。
---
### 二、业务闭环缺失的关键功能
#### 4. 提款 / 出金
当前已实现充值Recharge全流程——银行转账、USDT、截图上传、审核、入账。但完全没有提款/出金功能。玩家赢了钱却无法主动申请提款,这在业务闭环上是最大的缺口。至少需要:提款申请页面、提款方式选择、金额输入、提款记录与状态追踪。
#### 5. 注单结算/派彩通知
玩家下注后,赛事结算完成时没有任何主动通知机制。玩家只能反复刷"我的投注"才知道结果。需要:注单结算后推送通知(站内消息或 WebSocket 实时推送),至少在下注成功页和注单列表中有明确的"等待开奖"与"已开奖"状态区分。
#### 6. 充值/提款状态通知
充值订单审核通过或被拒绝后,玩家没有收到即时通知。目前只能在充值历史页面手动查看状态变化。
---
### 三、体验层面的缺失功能
#### 7. 赛事 / 球队搜索
当前赛事浏览路径只有:联赛筛选 → 时间筛选 → 列表滚动。当赛事数量多时,玩家无法通过关键词搜索球队名或联赛名快速定位比赛。需要在赛事列表页或首页增加搜索框。
#### 8. 投注单清空确认
BetSlipDrawer 中清空投注单没有二次确认弹窗,容易误操作导致已选择的投注项丢失。
#### 9. 投注单赔率实时更新提示
PRD 7.3 节要求"赔率已变化,拒绝下注,提示重新确认"。当前提交时校验赔率版本会报错,但在投注单停留期间没有实时检测赔率变化并主动标记(如高亮变化项、显示新旧赔率对比),玩家只有在点提交时才知道赔率变了。
#### 10. 盘口封盘倒计时
赛事列表和详情页没有显示距离封盘的倒计时。玩家不知道赛事何时关闭投注,影响下注决策。
#### 11. 快捷金额按钮自定义
PRD 4.3 节桌面端布局提到"快捷投注金额"。当前 BetSlipDrawer 中有固定的快捷金额按钮Min/5、+50、+100、Max但不能由后台配置或让玩家自定义。
#### 12. 我的投注 - 按赛事/时间筛选
MyBetsView 目前支持按状态筛选(全部/赢/输/待结算/走水),缺少按赛事、按时间范围筛选的能力,注单多了之后查找困难。
#### 13. 注单详情页 - 缺少分享/截图功能
玩家无法分享或保存注单截图。在社交平台传播场景中,注单截图是常见的拉新素材。
---
### 四、安全与合规相关缺失
#### 14. 负责任博彩 / 自我限额
PRD 18.1 节提到玩家每日投注上限和派彩上限由后台配置,但前台没有向玩家展示当前限额使用情况(如"今日已投注 X / 上限 Y"),也没有自我设置存款限额、投注限额、冷静期等负责任博彩工具。对于面向合法市场的平台,这通常是合规要求。
#### 15. 登录安全 - 缺少二次验证
玩家端登录只有密码 + 人机验证RobotVerify没有可选的短信验证码二次验证或 TOTP安全性偏弱。
#### 16. 会话管理
玩家无法查看当前活跃会话(哪些设备在登录),也无法远程踢出其他设备的会话。
---
### 五、运营与增长相关缺失
#### 17. 邀请好友入口
后端已有完整的邀请码系统(生成邀请码、设置返水比例、邀请列表、撤销),但玩家前台没有"邀请好友"入口页面,无法查看自己的邀请码、邀请链接、邀请记录和奖励。
#### 18. 活动 / 优惠专区
没有活动页面或优惠专区。运营无法通过前台展示限时活动、新人奖励、充值优惠等信息。目前只能依赖 Banner 和公告,缺乏独立的活动落地页。
#### 19. VIP / 等级体系
没有玩家等级体系。成熟平台通常根据投注量、充值额等划分 VIP 等级,不同等级享受不同返水比例、专属客服、提款加速等权益。
---
### 六、数据辅助功能缺失
#### 20. 赛事数据 / 历史战绩
赛事详情页只展示盘口和赔率,没有历史交锋记录、球队近期战绩、联赛积分榜等辅助数据。这些数据可以帮助玩家做投注决策,也是提升用户粘性的手段。
#### 21. 投注统计 / 个人报表
虽然有 BetStatsPanel 显示注单数/赢/输/待结算的基础统计,但缺少更丰富的个人投注报表:按时间段统计、按玩法统计、盈亏趋势图、返水收入统计等。
---
### 七、其他体验优化建议
| 编号 | 功能 | 说明 |
|---|---|---|
| 22 | 赛事收藏 / 关注 | 玩家可以收藏感兴趣的赛事,快速筛选查看 |
| 23 | 多赔率格式切换 | PRD 二期提到支持 Decimal/Malay/HK/Indo/American 格式,当前只有十进制 |
| 24 | PWA / 添加到主屏幕 | 移动端 H5 支持 PWA 安装提示,提升留存 |
| 25 | 网络状态提示 | 弱网或断网时显示明确提示,避免下注失败无感知 |
| 26 | 暗色/亮色主题切换 | 当前只有深色主题,没有亮色模式 |
| 27 | 系统公告弹窗 | 维护、重大赛事等场景的全屏弹窗通知,目前只有走马灯 |
| 28 | 盘口排序偏好 | 允许玩家调整盘口分组默认排序(如把让球放在独赢前面) |
---
### 优先级建议
**P0 - 必须补齐(影响业务闭环):**
- 提款功能(#4
- 邀请好友入口(#17)— 后端已就绪,只差前端页面
- 注单结算通知(#5
**P1 - 高优先级(影响核心体验):**
- 今日赛事板块(#1)— 后端数据已有,前端未消费
- 赛事搜索(#7
- 投注规则独立页面(#3
- 公告详情页(#2
- 负责任博彩 / 限额展示(#14
**P2 - 中优先级(体验优化):**
- 盘口封盘倒计时(#10
- 赔率变化实时标记(#9
- 注单筛选增强(#12
- 投注单清空确认(#8
- 充值/提款状态通知(#6
**P3 - 低优先级(长期迭代):**
- VIP 等级体系(#19
- 活动专区(#18
- 赛事数据/历史战绩(#20
- 多赔率格式(#23
- PWA#24

View File

@@ -16,7 +16,7 @@
padding: 0;
width: 100%;
height: 100%;
background: #001A33;
background: linear-gradient(180deg, #15120A 0%, #0A0A16 100%);
overflow: hidden;
}
@@ -28,7 +28,7 @@
#loading-overlay {
position: fixed;
inset: 0;
background: #001A33;
background: linear-gradient(180deg, #15120A 0%, #0A0A16 100%);
display: flex;
flex-direction: column;
align-items: center;
@@ -46,8 +46,8 @@
.spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(255, 255, 255, 0.15);
border-top-color: rgba(255, 255, 255, 0.72);
border: 2px solid rgba(200, 168, 78, 0.3);
border-top-color: #c8a84e;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}

View File

@@ -4,16 +4,25 @@ import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/** 本地 dev:与 apps/api/.env 的 PORT 对齐,供 player/admin Vite 代理使用 */
export function resolveApiDevTarget() {
if (process.env.VITE_DEV_API_TARGET) return process.env.VITE_DEV_API_TARGET;
if (process.env.API_DEV_TARGET) return process.env.API_DEV_TARGET;
/** 本地 dev 默认端口3000 在部分 WindowsHyper-V 保留 29603059上会 EACCES */
const DEFAULT_DEV_API_PORT = '3100';
let port = '3000';
/** 与 apps/api/.env 的 PORT 对齐 */
export function resolveApiDevPort() {
if (process.env.PORT) return String(process.env.PORT);
let port = DEFAULT_DEV_API_PORT;
const envPath = resolve(repoRoot, 'apps/api/.env');
if (existsSync(envPath)) {
const match = readFileSync(envPath, 'utf8').match(/^PORT=(\d+)\s*$/m);
if (match) port = match[1];
}
return `http://localhost:${port}`;
return port;
}
/** 供 player/admin Vite 代理使用 */
export function resolveApiDevTarget() {
if (process.env.VITE_DEV_API_TARGET) return process.env.VITE_DEV_API_TARGET;
if (process.env.API_DEV_TARGET) return process.env.API_DEV_TARGET;
return `http://localhost:${resolveApiDevPort()}`;
}

View File

@@ -1,6 +1,7 @@
import { execSync } from 'node:child_process';
import { resolveApiDevPort } from './dev-api-target.mjs';
const port = process.argv[2] ?? '3000';
const port = process.argv[2] ?? resolveApiDevPort();
function killWindows(portNum) {
let out = '';