Compare commits

...

51 Commits

Author SHA1 Message Date
mars
f0b8e051ed fix(player): apply heading font to nav links, team names, league titles, betslip labels in scoped styles 2026-07-13 10:10:52 +08:00
mars
0a4e4c36c8 fix(player): redo font-family replacement without encoding corruption 2026-07-13 09:58:17 +08:00
mars
5a6a8195f8 fix(player): apply heading/data font tokens globally across all desktop components 2026-07-13 09:55:26 +08:00
mars
3af2fe72d4 feat(player): ui-ux-pro-max design system upgrade for theme-2 desktop 2026-07-13 09:42:23 +08:00
mars
4cbc846d05 feat(player): PC 端投注/资金/充值/返水列表改为弹窗详情
列表点击与深链统一打开桌面弹窗,并补齐冻结余额文案与审核日志补全。
2026-07-10 16:12:57 +08:00
mars
ea4764934a fix(player): 补充弹窗组件文件 DesktopBetDetailModal.vue 和 DesktopTxDetailModal.vue 2026-07-10 15:16:25 +08:00
mars
14268eccaa fix(player): PC端资金明细列表点击改为弹窗展示 - DesktopWalletDetailView 列表项点击从 router.push 改为打开 DesktopTxDetailModal 弹窗 2026-07-10 15:08:32 +08:00
mars
49594110a5 feat(player): theme-2 PC auth layout and correct-score quick bet popover 2026-07-07 14:41:43 +08:00
9be3f87b2b fix(player): 未登录悬停赔率仅展示快算弹层,下注时再提示登录 2026-07-06 10:27:37 +08:00
6828067a89 fix(player): theme-2 首页移除重复公告走马灯,仅保留 Shell 顶部条 2026-07-06 09:56:33 +08:00
3908e5539d feat(player): 合并 main 桌面快算能力并完善 theme-2 球赛/首页体验
- 恢复桌面首页焦点大卡布局、悬停下注、VS 高亮与注单动画,移除焦点卡背景图
- 对齐主分支快算弹层上下定位与卡片 engaged 状态;详情页补充 cancelClose/高亮
- 球赛/优胜冠军页恢复 MatchBetCard、冠军卡悬停快算与 OutrightBetModal
- 首页 hotMatches 接口返回 markets,修复卡片切换玩法误显暂无盘口
- 补充 i18n:bet.all、no_market_odds、quick_bet、outright_view_all 等;移除 Tab 表情符号
2026-07-03 11:44:09 +08:00
9daf919d88 feat(player): 优化桌面端优胜冠军相关组件与侧边栏布局,适配主题变量 2026-07-01 17:59:56 +08:00
edc54e25f3 feat(player-desktop): 优化主页赛事卡片布局、统一注单按钮颜色、添加置顶精简走马灯 2026-06-30 17:29:39 +08:00
e4ce5941a5 fix(player): 优化公告/消息详情页布局,隐藏右侧投注单
- 桌面端公告、消息详情页不再展示右侧投注单栏
- Hub 详情区移除重复返回按钮与竖排「查看全部公告」文字
- 左侧公告列表保留返回首页按钮
2026-06-30 16:38:12 +08:00
7f563326d8 feat(player+api): 桌面端钱包/注单栏重构,首页快速下注与移动端体验优化
桌面端:
- 新增 DesktopWalletPageHeader 统一钱包子导航,移除重复大标题
- 重构我的余额页布局(居中窄卡片 + 余额/统计/账户设置)
- 新增 DesktopBetSlipRail 可折叠注单侧栏,优化 BetSlipPanel 交互
- 首页 upcoming 赛事卡片支持胜平负/让球/大小球快速下注
- 优化 DesktopShell 布局、3D 轮播、各账户/消息/充值页面样式

移动端:
- 重构 MobileWalletView 钱包页 UI
- 修复 MarketSelectionsPanel 下注区黑色背景问题
- 新增 PageBackButton 与 useSmartBack 智能返回逻辑
- 优化公告/消息/个人中心等详情页导航

API:
- listUpcomingPublished 支持 includeMarkets,返回首页卡片所需核心玩法
2026-06-30 16:11:27 +08:00
ef5b9416cc fix(player): resolve missing AnnouncementMarquee import in MainLayout 2026-06-30 11:06:19 +08:00
4d8389a716 style(theme-2): replace gold components and colors with blue-white scheme on desktop 2026-06-30 11:04:13 +08:00
b327f65c63 style(theme-2): rewrite desktop CSS for light mode blue-white 2026-06-30 10:55:08 +08:00
c37b1a8548 Merge main into theme-2 and resolve conflicts for desktop architecture 2026-06-30 10:53:34 +08:00
7f30408524 fix(player): theme-2 使用正确的 theme-color 2026-06-23 16:29:57 +08:00
01698bf410 fix(player): 修复主题分支网站图标不显示
favicon.svg 原外链 /logo.png 浏览器无法加载;改为内嵌 base64

index.html 增加 logo.png 作为 favicon 回退;theme-3 theme-color 对齐 #0F2027
2026-06-23 16:29:31 +08:00
713739475b feat(player): theme-2 钱包页与流水展示优化
- 钱包首页、明细、交易详情适配 Pinnacle 浅色主题

- WalletStatsPanel 统计面板布局与样式更新

- 流水金额展示统一使用 txAmountClass / txDisplayType
2026-06-23 15:56:53 +08:00
f9d68d94ce feat(player): theme-2 认证页 zt2 背景与表单样式优化
- 登录/注册/找回密码使用 zt2.webp,移除暗色渐变遮罩

- 表单标题、标签、链接改为深蓝/灰色,适配 Pinnacle 浅色主题

- 更新 zt2.webp 资源文件
2026-06-23 15:56:13 +08:00
6ba541c133 feat(player): theme-2 登录/注册/找回密码使用 zt2.webp 背景
替换 h5bg.webp,仅 theme-2 玩家端认证页
2026-06-23 15:24:25 +08:00
43fa8ebef0 fix(dev): 本地 API 默认端口改为 3100 避开 Windows 保留段
Hyper-V 常保留 2960-3059,3000 会 EACCES;ensure-port-free 与 Vite 代理从 apps/api/.env 读取 PORT
2026-06-23 15:11:50 +08:00
b01213f637 feat(catalog): 玩家端隐藏已结算赛事与冠军盘联赛
- listPublished/listUpcomingPublished 排除 SETTLED 及冠军盘已结算联赛

- outright.listForPlayer 仅返回 PUBLISHED

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

- 补充 matches/outright 单测
2026-06-23 15:02:38 +08:00
a8b7d5763f fix(player): captcha canvas colors follow theme CSS variables (theme-2) 2026-06-23 14:33:40 +08:00
dcce14cf74 fix(admin): 恢复 Settlement adminSelectionLabel 导入并修正 walletRemarkLabel 类型 2026-06-23 14:00:39 +08:00
26e2adf786 sync(theme-2): 从 main 同步优胜赛结算态与钱包流水展示优化
- checkout shared/api/admin 自 d3c2114
- player: outright 结算态逻辑、wallet txAmountClass、i18n 新增 key(保留蓝白主题样式)
2026-06-23 13:49:43 +08:00
021d216613 fix(theme-2): 补全首页 bento 与查看全部赛事 i18n 键
恢复 home.bento_desc、view_bet_history、view_all_matches 三语文案,修复同步 main 时丢失的翻译。
2026-06-22 16:59:38 +08:00
5e7d6bc23f sync(admin): 从 main 同步列表布局、滚动固定与侧栏选中
checkout main: App.vue, ManageLayout, AgentManager, Matches, LeagueMatches*, AgentDirectPlayersView
2026-06-22 16:51:36 +08:00
fa8d3fbf0c sync(theme-2): 同步 main Admin 菜单权限修复与 i18n 2026-06-22 16:23:10 +08:00
27dc9eb3b0 sync(theme-2): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-2 样式。
2026-06-22 14:10:54 +08:00
fa7b8cff25 feat(admin): 同步单赛事筛选与排序功能 (从 main 分支) 2026-06-18 17:45:58 +08:00
dd7802c6f7 style: separate header back-bar and tabs in InboxHubView on theme-2 2026-06-18 17:37:13 +08:00
074531f705 feat: align homepage banner navigation and default banner logic with main 2026-06-18 17:30:32 +08:00
c61d3c68f2 fix(admin): 发版 chunk 自动刷新、nginx 缓存策略与审计页 i18n
lazy route 加载失败时刷新入口;index.html 不缓存;补全 audit.col.operator/ip 等拆包遗漏 key。
2026-06-18 16:59:52 +08:00
c5a5e66bbd perf(admin): 实现切页即时导航与列表 loading 壳
非阻塞 session 刷新、侧边栏 chunk 预取、KeepAlive 对齐,以及按 tab 延迟加载重型列表页。
2026-06-18 16:46:57 +08:00
f2f6927a75 feat(admin): 将新增赛事按钮移至联赛操作栏,并移除列表下方的旧按钮 2026-06-18 15:41:28 +08:00
e1d33dde9a 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:39 +08:00
ea695012cc docs: 从 main 同步 AGENTS、文档与部署脚本 2026-06-18 14:58:25 +08:00
3212cd6040 fix(player): 冠军盘弹窗支持赔率变更轮询与确认下注
- 打开弹窗后定时拉取最新赔率并展示变更提示
- 赔率变动时按钮切换为「接受变更并下注」,提交前自动锁定新版本
- 接口返回 ODDS_CHANGED 时刷新赔率并引导用户重新确认
2026-06-18 13:54:22 +08:00
33e7092b77 style(theme-2): 修复详情页顶部导航栏上方的空白边距及滚动内容穿透问题 2026-06-18 10:54:18 +08:00
f4c71b22f2 style(theme-2): remove Message Center shortcut from profile page 2026-06-18 10:46:16 +08:00
75bd5c3512 feat(theme-2): replace quick entries with bento grid, implement hot match carousel, limit upcoming matches list, and fix layout details 2026-06-18 10:42:09 +08:00
740799a820 feat(theme-2): sync match search feature from main 2026-06-18 09:45:53 +08:00
e56029225e feat(theme-2): sync inbox/announcements/presence/deposit from main 2026-06-18 09:38:33 +08:00
ec51b675e3 fix(player): 赛事详情页恢复顶部 8px 间距(theme-2)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:18:44 +08:00
9f1d263741 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:23 +08:00
cb4e48b9f4 feat(player): ???????????????
???????????????????????????????????????????????????????????

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:10:11 +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
127 changed files with 11175 additions and 4642 deletions

View File

@@ -189,8 +189,8 @@ export class PlayerController {
const [banners, announcements, allMatches, upcomingMatches, inboxEnabled] = await Promise.all([
this.content.listActive('BANNER', locale),
this.content.listActiveAnnouncements(locale),
this.matches.listPublished(locale, undefined, { includeMarkets: false }),
this.matches.listUpcomingPublished(locale),
this.matches.listPublished(locale, undefined, { includeMarkets: true }),
this.matches.listUpcomingPublished(locale, { includeMarkets: true }),
this.systemConfig.getInboxFeatureEnabled(),
]);
const timeZone = safeTimeZone(headerTimeZone);

View File

@@ -1592,17 +1592,42 @@ export class MatchesService {
);
}
/** 首页卡片快速下注所需的核心玩法类型 */
private static readonly HOME_CARD_MARKET_TYPES = [
'FT_1X2',
'FT_HANDICAP',
'FT_OVER_UNDER',
] as const;
/** 未来 N 天内开赛的已发布赛事(按开赛时间升序,不限 isHot */
async listUpcomingPublished(
locale = 'en-US',
options?: { limit?: number; days?: number },
options?: { limit?: number; days?: number; includeMarkets?: boolean },
) {
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);
const marketsRelation = includeMarkets
? {
where: {
status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] },
showOnPlayer: true,
marketType: { in: [...MatchesService.HOME_CARD_MARKET_TYPES] },
},
include: {
selections: {
where: { status: { in: ['OPEN', 'SUSPENDED', 'CLOSED'] } },
orderBy: { sortOrder: 'asc' as const },
},
},
orderBy: { sortOrder: 'asc' as const },
}
: this.playerMarketStatusInclude;
const matches = await this.prisma.match.findMany({
where: {
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
@@ -1625,14 +1650,14 @@ export class MatchesService {
homeTeam: true,
awayTeam: true,
score: true,
markets: this.playerMarketStatusInclude,
markets: marketsRelation,
},
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
take: limit,
});
return Promise.all(
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: true })),
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: !includeMarkets })),
);
}

View File

@@ -3,18 +3,24 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="theme-color" content="#003D6B" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="TheBet365" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<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" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@400;500;600;700&family=Outfit:wght@200;300;400&family=Russo+One&display=swap"
rel="stylesheet"
/>
<title>TheBet365</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #000; }
html, body { margin: 0; padding: 0; height: 100%; background: #F5F7FA; }
#app-loading {
position: fixed;
inset: 0;
@@ -22,7 +28,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: #000;
background: #F5F7FA;
z-index: 9999;
}
#app-loading svg {
@@ -40,16 +46,16 @@
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="gs-track" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#D4AF37" stop-opacity="0.18" />
<stop offset="50%" stop-color="#F0D875" stop-opacity="0.25" />
<stop offset="100%" stop-color="#D4AF37" stop-opacity="0.18" />
<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="#FFF4C8" />
<stop offset="22%" stop-color="#F0D875" />
<stop offset="50%" stop-color="#D4AF37" />
<stop offset="78%" stop-color="#B8942B" />
<stop offset="100%" stop-color="#8B6914" />
<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" />
@@ -59,9 +65,9 @@
</feMerge>
</filter>
<radialGradient id="gs-dot" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#FFF4C8" />
<stop offset="60%" stop-color="#F0D875" />
<stop offset="100%" stop-color="#D4AF37" />
<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" />
@@ -74,4 +80,4 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View File

@@ -52,7 +52,7 @@ function goDetail() {
gap: 10px;
margin: -4px -2px 14px;
padding: 10px 12px;
background: #141414;
background: #EFF6FF;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
@@ -65,20 +65,27 @@ function goDetail() {
.marquee-bar.embedded {
margin: 0;
padding: 8px 12px;
padding: 3px 12px;
border: none;
border-radius: 0;
border-bottom: 1px solid var(--border);
background: #111;
background: #F8FAFC;
}
.marquee-bar.embedded .marquee-badge {
padding: 3px 8px;
font-size: 10px;
padding: 1px 4px;
font-size: 9px;
background: rgba(0, 0, 0, 0.06);
color: var(--text-muted);
font-weight: 600;
letter-spacing: normal;
border-radius: 3px;
}
.marquee-bar.embedded .marquee-text {
font-size: 12px;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
}
.marquee-badge {
@@ -87,9 +94,9 @@ function goDetail() {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
color: #1A1000;
background: var(--gradient-gold);
border: 1px solid var(--border-gold-soft);
color: #fff;
background: var(--primary);
border: none;
border-radius: 4px;
}
@@ -103,13 +110,13 @@ function goDetail() {
.marquee-track {
display: flex;
width: max-content;
animation: marquee-scroll 18s linear infinite;
animation: marquee-scroll 35s linear infinite;
pointer-events: none;
}
.marquee-text {
flex-shrink: 0;
padding-right: 80px;
padding-right: 240px;
font-size: 13px;
font-weight: 600;
color: var(--primary-light);

View File

@@ -24,10 +24,10 @@ 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);
color: var(--primary-light);
border: 1px solid var(--primary);
background: var(--bg-card);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.15);
color: var(--primary);
font-size: 13px;
font-weight: 700;
text-align: center;

View File

@@ -44,10 +44,10 @@ const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl'));
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.35));
background: rgba(17, 17, 17, 0.92);
color: var(--primary-light, #c8a84e);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
border: 1px solid #E5E7EB;
background: #FFFFFF;
color: #003D6B;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
@@ -57,7 +57,7 @@ const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl'));
}
.back-top-btn:active {
background: rgba(200, 168, 78, 0.16);
background: rgba(0, 61, 107, 0.06);
transform: scale(0.96);
}

View File

@@ -168,20 +168,20 @@ onUnmounted(stopAutoPlay);
<style scoped>
.carousel {
position: relative;
margin: -12px -16px 14px;
margin: -12px -16px 10px;
overflow: hidden;
}
.media {
position: relative;
overflow: hidden;
background: #05080d;
background: #E8EDF2;
}
.viewport {
overflow: hidden;
width: 100%;
height: clamp(148px, 41.86vw, 180px);
height: clamp(120px, 34vw, 150px);
}
.track {
@@ -199,9 +199,7 @@ onUnmounted(stopAutoPlay);
align-items: center;
justify-content: center;
overflow: hidden;
background:
linear-gradient(135deg, rgba(7, 18, 31, 0.94), rgba(6, 8, 12, 0.98)),
#070d15;
background: linear-gradient(135deg, #F5F7FA, #E8EDF2);
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
@@ -213,8 +211,8 @@ onUnmounted(stopAutoPlay);
inset: 0;
z-index: 1;
background:
linear-gradient(90deg, rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.03) 48%, rgba(0, 0, 0, 0.18));
box-shadow: inset 0 -1px 0 rgba(212, 175, 55, 0.16);
linear-gradient(90deg, rgba(245, 247, 250, 0.12), rgba(255, 255, 255, 0.03) 48%, rgba(245, 247, 250, 0.12));
box-shadow: inset 0 -1px 0 rgba(0, 61, 107, 0.08);
pointer-events: none;
}
@@ -225,7 +223,7 @@ onUnmounted(stopAutoPlay);
height: calc(100% + 36px);
object-fit: cover;
filter: blur(16px);
opacity: 0.42;
opacity: 0.3;
transform: scale(1.04);
pointer-events: none;
}
@@ -248,29 +246,30 @@ onUnmounted(stopAutoPlay);
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(0, 61, 107, 0.08), #E8EDF2);
font-size: 22px;
font-weight: 800;
color: var(--primary-light);
font-weight: 700;
color: #003D6B;
}
.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);
border: 1px solid var(--border);
padding-bottom: 5px;
font-size: 22px;
background: rgba(255, 255, 255, 0.85);
color: #003D6B;
border: 1px solid #E5E7EB;
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.08);
}
.nav.prev { left: 8px; }
@@ -278,29 +277,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(0, 61, 107, 0.2);
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);
width: 18px;
border-radius: 5px;
background: #003D6B;
border-color: #003D6B;
box-shadow: none;
}
</style>

View File

@@ -65,9 +65,9 @@ function close() {
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid var(--border-gold-soft);
background: rgba(0, 0, 0, 0.2);
color: var(--primary-light);
border: 1px solid #E5E7EB;
background: #FFFFFF;
color: #003D6B;
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: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
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), 0 0 24px rgba(212, 175, 55, 0.08);
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: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
background: #F5F7FA;
color: #6B7280;
font-size: 14px;
}
.title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
color: #003D6B;
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: #003D6B;
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: #003D6B;
color: #FFFFFF;
border: none;
}
</style>

View File

@@ -149,15 +149,19 @@ function goDetail() {
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
transition: background 0.15s;
transition: background 0.15s, box-shadow 0.15s;
-webkit-tap-highlight-color: transparent;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.bet-card:active {
background: rgba(255, 255, 255, 0.02);
background: #F5F7FA;
}
.card-body {
@@ -178,9 +182,9 @@ function goDetail() {
.cashback-tag {
font-size: 10px;
font-weight: 700;
color: #f0b90b;
background: rgba(240, 185, 11, 0.1);
border: 1px solid rgba(240, 185, 11, 0.25);
color: #F8971F;
background: rgba(248, 151, 31, 0.08);
border: 1px solid rgba(248, 151, 31, 0.2);
border-radius: 4px;
padding: 1px 6px;
}
@@ -188,9 +192,9 @@ function goDetail() {
.bet-type-tag {
font-size: 10px;
font-weight: 700;
color: var(--primary-light, #c8a84e);
background: rgba(200, 168, 78, 0.12);
border: 1px solid rgba(200, 168, 78, 0.25);
color: #003D6B;
background: rgba(0, 61, 107, 0.06);
border: 1px solid rgba(0, 61, 107, 0.15);
border-radius: 4px;
padding: 1px 6px;
letter-spacing: 0.02em;
@@ -198,7 +202,7 @@ function goDetail() {
.card-date {
font-size: 10px;
color: #555;
color: #6B7280;
font-weight: 600;
flex-shrink: 0;
margin-left: auto;
@@ -207,7 +211,7 @@ function goDetail() {
.title {
font-size: 14px;
font-weight: 800;
color: #e8e8e8;
color: #1A1A2E;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -221,7 +225,7 @@ function goDetail() {
.pick-label {
font-size: 11px;
color: #888;
color: #6B7280;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
@@ -233,8 +237,8 @@ function goDetail() {
.odds-badge {
font-size: 11px;
font-weight: 800;
color: #c8a84e;
background: rgba(200, 168, 78, 0.1);
color: #F8971F;
background: rgba(248, 151, 31, 0.08);
border-radius: 4px;
padding: 1px 6px;
flex-shrink: 0;
@@ -250,24 +254,24 @@ function goDetail() {
.stake-amt {
font-size: 11px;
color: #666;
color: #6B7280;
font-weight: 600;
}
.return-amt {
font-size: 15px;
font-weight: 900;
color: #888;
color: #6B7280;
}
.return-amt.highlight { color: #3db865; }
.return-amt.pending { color: #e8c84a; }
.return-amt.lost { color: #e05050; }
.return-amt.highlight { color: #059669; }
.return-amt.pending { color: #F8971F; }
.return-amt.lost { color: #DC2626; }
.chevron {
flex-shrink: 0;
font-size: 18px;
color: #333;
color: #E5E7EB;
margin-left: 2px;
line-height: 1;
}

View File

@@ -685,13 +685,13 @@ watch(
.drawer {
width: 100%;
max-height: 88vh;
background: #101010;
background: var(--bg-card);
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;
box-shadow: 0 -20px 44px rgba(0, 0, 0, 0.48);
box-shadow: 0 -12px 32px rgba(0, 0, 0, 0.12);
}
.drawer-head {
@@ -703,8 +703,8 @@ watch(
align-items: flex-start;
gap: 12px;
padding: 14px 14px 10px;
background: linear-gradient(180deg, #1d1d1d 0%, #111 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: linear-gradient(180deg, #FFFFFF 0%, var(--bg-body) 100%);
border-bottom: 1px solid var(--border);
}
.drawer-kicker {
@@ -728,7 +728,7 @@ watch(
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
background: var(--bg-hover);
color: var(--text-muted);
font-size: 18px;
padding: 0;
@@ -742,8 +742,8 @@ watch(
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(212, 175, 55, 0.08);
border-bottom: 1px solid rgba(212, 175, 55, 0.18);
background: rgba(0, 102, 204, 0.06);
border-bottom: 1px solid rgba(0, 102, 204, 0.12);
color: var(--text-muted);
font-size: 12px;
}
@@ -759,7 +759,7 @@ watch(
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
background: var(--bg-hover);
color: var(--text-muted);
padding: 0;
}
@@ -769,23 +769,23 @@ watch(
grid-template-columns: repeat(2, 1fr);
padding: 8px 12px 0;
gap: 6px;
background: #101010;
background: var(--bg-body);
}
.slip-tab {
min-height: 38px;
border-radius: 6px 6px 0 0;
border: 1px solid var(--border);
background: #171717;
background: var(--bg-card);
color: var(--text-muted);
font-size: 13px;
font-weight: 800;
}
.slip-tab.active {
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.13);
color: var(--primary-light);
border-color: var(--border-active);
background: rgba(0, 102, 204, 0.08);
color: var(--primary);
}
.tab-count {
@@ -797,7 +797,7 @@ watch(
margin-left: 6px;
border-radius: 9px;
background: var(--primary);
color: #111;
color: #fff;
font-size: 11px;
font-weight: 900;
}
@@ -822,15 +822,15 @@ watch(
gap: 10px;
align-items: center;
padding: 11px 12px;
background: #151515;
border: 1px solid #292929;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 7px;
margin-bottom: 8px;
}
.slip-item--single {
border-color: var(--border-gold-soft);
background: linear-gradient(180deg, rgba(212, 175, 55, 0.12), rgba(18, 18, 18, 0.94));
border-color: var(--border-active);
background: linear-gradient(180deg, rgba(0, 102, 204, 0.06), var(--bg-card));
}
.item-main {
@@ -891,14 +891,12 @@ watch(
min-height: 48px;
padding: 0 14px;
border-radius: 8px;
background: linear-gradient(180deg, #4b370d 0%, #241905 100%);
border: 1px solid var(--primary-light);
color: #ffe892;
background: linear-gradient(180deg, var(--primary-light) 0%, var(--primary) 100%);
border: 1px solid var(--border-active);
color: #fff;
font-size: 14px;
font-weight: 900;
box-shadow:
0 0 0 1px rgba(255, 232, 146, 0.18) inset,
0 6px 18px rgba(212, 175, 55, 0.18);
box-shadow: 0 4px 12px rgba(0, 61, 107, 0.15);
}
.btn-add-parlay span {
@@ -908,8 +906,8 @@ watch(
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary-light);
color: #191100;
background: #fff;
color: var(--primary);
font-size: 14px;
line-height: 1;
}
@@ -963,8 +961,8 @@ watch(
.stake-area {
margin: 12px 0 12px;
padding: 12px;
background: #151515;
border: 1px solid #292929;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 8px;
}
@@ -990,17 +988,17 @@ watch(
grid-template-columns: auto 1fr auto;
align-items: center;
margin-top: 10px;
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
border-radius: 6px;
overflow: hidden;
background: #0c0c0c;
background: var(--bg-card);
}
.currency {
padding: 0 10px;
color: var(--text-muted);
font-size: 13px;
border-right: 1px solid #2d2d2d;
border-right: 1px solid var(--border);
}
.stake-input-row input {
@@ -1065,7 +1063,7 @@ watch(
.quick-stakes button {
min-height: 50px;
background: #7b787d;
background: var(--bg-body);
color: var(--text);
font-size: 20px;
font-weight: 800;
@@ -1087,9 +1085,9 @@ watch(
grid-template-columns: 1fr;
gap: 10px;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(14, 14, 14, 0.98);
border-top: 1px solid var(--border-gold-soft);
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
background: var(--bg-card);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
}
.drawer-foot--split {
@@ -1101,7 +1099,7 @@ watch(
min-height: 48px;
border-radius: 7px;
background: linear-gradient(180deg, var(--primary-light), var(--primary));
color: #111;
color: #fff;
font-size: 16px;
font-weight: 900;
}

View File

@@ -79,12 +79,12 @@ const stats = computed(() => {
<style scoped>
.stats-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 14px 12px 10px;
margin-bottom: 12px;
backdrop-filter: blur(10px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.stats-row {
@@ -110,27 +110,28 @@ const stats = computed(() => {
font-size: 18px;
font-weight: 900;
font-variant-numeric: tabular-nums;
color: #1A1A2E;
}
.stat-val.small {
font-size: 14px;
color: var(--text-muted);
color: #6B7280;
}
.stat-val.gold {
color: var(--primary-light);
color: #003D6B;
}
.stat-item.won .stat-val { color: #3db865; }
.stat-item.lost .stat-val { color: #e05050; }
.stat-item.pending .stat-val { color: #e8c84a; }
.stat-item.push .stat-val { color: #888; }
.stat-item.won .stat-val { color: #059669; }
.stat-item.lost .stat-val { color: #DC2626; }
.stat-item.pending .stat-val { color: #F8971F; }
.stat-item.push .stat-val { color: #6B7280; }
.stat-label {
display: block;
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
color: #6B7280;
letter-spacing: 0.04em;
margin-top: 2px;
}
@@ -144,7 +145,7 @@ const stats = computed(() => {
}
.stats-bar.empty {
background: rgba(255, 255, 255, 0.08);
background: #E8EDF2;
}
.bar-seg {
@@ -152,8 +153,8 @@ const stats = computed(() => {
transition: flex 0.3s ease;
}
.bar-seg.won { background: #3db865; }
.bar-seg.lost { background: #e05050; }
.bar-seg.pending { background: #e8c84a; }
.bar-seg.push { background: #444; }
.bar-seg.won { background: #059669; }
.bar-seg.lost { background: #DC2626; }
.bar-seg.pending { background: #F8971F; }
.bar-seg.push { background: #6B7280; }
</style>

View File

@@ -16,7 +16,7 @@ interface Particle {
color: string; shape: number;
}
const COLORS = ['#D4AF37', '#FFD700', '#F0C040', '#C8A02E', '#E6C84C', '#B8960C'];
const COLORS = ['#003D6B', '#005B9F', '#0066CC', '#3388DD', '#1A5FA0', '#4499E0'];
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="#D4AF37" stroke-width="3" />
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="#D4AF37" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
<circle class="check-circle" cx="26" cy="26" r="24" fill="none" stroke="#003D6B" stroke-width="3" />
<path class="check-mark" d="M14 27l7 7 16-16" fill="none" stroke="#003D6B" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<p class="success-text">{{ t('bet.place_success') }}</p>
</div>
@@ -125,7 +125,7 @@ onUnmounted(stop);
position: fixed;
inset: 0;
z-index: 300;
background: rgba(0, 0, 0, 0.82);
background: rgba(245, 247, 250, 0.88);
display: flex;
align-items: center;
justify-content: center;
@@ -166,7 +166,7 @@ onUnmounted(stop);
}
.success-text {
color: var(--primary-light);
color: #003D6B;
font-size: 18px;
font-weight: 800;
letter-spacing: 0.04em;

View File

@@ -144,10 +144,11 @@ onUnmounted(() => {
padding: 0 8px 0 8px;
min-width: 0;
cursor: pointer;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #141414;
background: #FFFFFF;
box-sizing: border-box;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.chip-body {
@@ -159,7 +160,7 @@ onUnmounted(() => {
.chip-label {
font-size: 8px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
@@ -172,14 +173,14 @@ onUnmounted(() => {
.chip-amount {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
color: #003D6B;
white-space: nowrap;
line-height: 1.15;
}
.chevron {
font-size: 10px;
color: var(--primary-light);
color: #003D6B;
transition: transform 0.2s;
margin-left: 0;
}
@@ -195,10 +196,10 @@ onUnmounted(() => {
width: min(260px, 78vw);
padding: 12px 14px;
z-index: 130;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #141414;
box-shadow: var(--shadow);
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.cash-panel::before {
@@ -212,21 +213,22 @@ onUnmounted(() => {
font-size: 13px;
font-weight: 600;
padding: 6px 0;
color: #1A1A2E;
}
.panel-row.muted {
color: var(--text-muted);
color: #6B7280;
}
.panel-row.highlight {
color: var(--primary-light);
color: #003D6B;
font-weight: 800;
font-size: 14px;
}
.panel-divider {
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-gold-soft), transparent);
background: #E5E7EB;
margin: 4px 0;
}
@@ -235,9 +237,9 @@ onUnmounted(() => {
padding: 8px 0;
margin-top: 4px;
border-radius: 6px;
border: 1px solid var(--primary, #c8a84e);
background: rgba(200, 168, 78, 0.12);
color: var(--primary-light, #c8a84e);
border: 1px solid #003D6B;
background: transparent;
color: #003D6B;
font-size: 13px;
font-weight: 700;
cursor: pointer;
@@ -245,7 +247,7 @@ onUnmounted(() => {
}
.panel-recharge-btn:active {
background: rgba(200, 168, 78, 0.24);
background: rgba(0, 61, 107, 0.06);
}
.backdrop {

View File

@@ -103,18 +103,18 @@ function onConfirm() {
.confirm-modal {
width: 100%;
max-width: 340px;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px 18px 16px;
box-shadow: 0 0 24px rgba(212, 175, 55, 0.08);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
.confirm-title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 800;
color: var(--gold, #c8a84e);
color: var(--text);
text-align: center;
line-height: 1.35;
}
@@ -123,7 +123,7 @@ function onConfirm() {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.6;
color: #c8c8c8;
color: var(--text-muted);
text-align: center;
white-space: pre-line;
}
@@ -149,15 +149,15 @@ function onConfirm() {
}
.confirm-btn.cancel {
border: 1px solid #333;
border: 1px solid var(--border);
background: transparent;
color: #888;
color: var(--text-muted);
}
.confirm-btn.confirm {
border: none;
background: linear-gradient(135deg, #d4a017, #e8c84a);
color: #1a1a1a;
background: var(--primary);
color: #fff;
}
.confirm-btn.confirm.danger {
@@ -184,4 +184,25 @@ function onConfirm() {
.confirm-fade-leave-to .confirm-modal {
transform: scale(0.96);
}
/* === Desktop Theme Overrides === */
:global(.is-desktop) .confirm-modal {
background: var(--bg-card);
border: 1px solid var(--border);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.1);
}
:global(.is-desktop) .confirm-title {
color: var(--text);
}
:global(.is-desktop) .confirm-message {
color: var(--text-muted);
}
:global(.is-desktop) .confirm-btn.cancel {
border-color: var(--border);
color: var(--text-muted);
}
:global(.is-desktop) .confirm-btn.confirm {
background: var(--primary);
color: #fff;
}
</style>

View File

@@ -32,12 +32,27 @@ const showMessageActions = computed(
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value && !selectedMessageId.value,
);
const isBettingDesktop = computed(() => {
if (!isDesktop.value) return false;
const hideOnMessagesPage = computed(
() => isDesktop.value && route.path.startsWith('/messages'),
);
const offsetForBetSlip = computed(() => {
if (!isDesktop.value || hideOnMessagesPage.value) return false;
const p = route.path;
return p === '/bet' || p.startsWith('/match/') || p.startsWith('/outright/');
if (p === '/bets' || p.startsWith('/bets/')) return false;
if (p.startsWith('/wallet') || p.startsWith('/profile')) return false;
return true;
});
watch(
() => route.path,
(path) => {
if (isDesktop.value && path.startsWith('/messages') && isOpen.value) {
close();
}
},
);
watch(isOpen, (opened) => {
if (opened) {
if (!inboxEnabled.value) activeTab.value = 'support';
@@ -86,7 +101,11 @@ onMounted(() => {
</script>
<template>
<div class="floating-mailbox" :class="{ 'is-betting-desktop': isBettingDesktop }">
<div
v-if="!hideOnMessagesPage"
class="floating-mailbox"
:class="{ 'is-betting-desktop': offsetForBetSlip }"
>
<!-- Floating Button -->
<button
type="button"
@@ -152,7 +171,12 @@ onMounted(() => {
<div class="popup-body">
<template v-if="activeTab === 'messages' && inboxEnabled">
<MobileMessageDetailView v-if="selectedMessageId" :id="selectedMessageId" embedded @back="selectedMessageId = null" />
<MobileMessageDetailView
v-if="selectedMessageId"
:id="selectedMessageId"
embedded
@back="selectedMessageId = null"
/>
<MessageListPanel v-else class="panel-content" @click-message="handleMessageClick" />
</template>
<CustomerServicePanel v-else class="panel-content" />
@@ -224,10 +248,10 @@ onMounted(() => {
width: 380px;
height: 600px;
max-height: calc(100vh - 120px);
background: var(--bg-card, #141414);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-md);
display: flex;
flex-direction: column;
overflow: hidden;
@@ -240,7 +264,7 @@ onMounted(() => {
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
background: var(--bg-body);
height: 48px;
}
@@ -264,7 +288,7 @@ onMounted(() => {
}
.tab-btn:hover {
color: #fff;
color: var(--primary);
}
.tab-btn.active {
@@ -273,8 +297,8 @@ onMounted(() => {
}
.tab-badge {
background: rgba(255, 255, 255, 0.1);
color: #fff;
background: var(--bg-hover);
color: var(--text-muted);
font-size: 11px;
padding: 2px 6px;
border-radius: 10px;
@@ -282,7 +306,7 @@ onMounted(() => {
.tab-btn.active .tab-badge {
background: var(--primary);
color: #141414;
color: #fff;
}
.header-actions {
@@ -305,8 +329,8 @@ onMounted(() => {
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
background: var(--bg-hover);
color: var(--primary);
}
.action-btn.danger:hover {
@@ -315,13 +339,13 @@ onMounted(() => {
}
.action-btn.close:hover {
background: rgba(255, 255, 255, 0.1);
background: var(--bg-hover);
}
.popup-body {
height: calc(100% - 48px);
position: relative;
background: #0f0f0f;
background: var(--bg-body);
overflow-y: auto;
}

View File

@@ -19,7 +19,7 @@ const wrapperStyle = computed(() => {
<template>
<svg
:class="['gold-spinner', { 'is-active': active }]"
:class="['spinner', { 'is-active': active }]"
:width="size ?? 48"
:height="size ?? 48"
viewBox="0 0 48 48"
@@ -28,44 +28,44 @@ const wrapperStyle = computed(() => {
:style="!active ? wrapperStyle : undefined"
>
<defs>
<linearGradient id="gs-track" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#D4AF37" stop-opacity="0.18" />
<stop offset="50%" stop-color="#F0D875" stop-opacity="0.25" />
<stop offset="100%" stop-color="#D4AF37" stop-opacity="0.18" />
<linearGradient id="sp-track" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#003D6B" stop-opacity="0.15" />
<stop offset="50%" stop-color="#005B9F" stop-opacity="0.22" />
<stop offset="100%" stop-color="#003D6B" stop-opacity="0.15" />
</linearGradient>
<linearGradient id="gs-arc" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFF4C8" />
<stop offset="22%" stop-color="#F0D875" />
<stop offset="50%" stop-color="#D4AF37" />
<stop offset="78%" stop-color="#B8942B" />
<stop offset="100%" stop-color="#8B6914" />
<linearGradient id="sp-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">
<filter id="sp-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="#FFF4C8" />
<stop offset="60%" stop-color="#F0D875" />
<stop offset="100%" stop-color="#D4AF37" />
<radialGradient id="sp-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(#sp-track)" stroke-width="3" fill="none" />
<circle
cx="24"
cy="24"
r="20"
stroke="url(#gs-arc)"
stroke="url(#sp-arc)"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-dasharray="31.4 94.2"
filter="url(#gs-glow)"
filter="url(#sp-glow)"
/>
<circle cx="24" cy="4" r="2.5" fill="url(#gs-dot)" filter="url(#gs-glow)">
<circle cx="24" cy="4" r="2.5" fill="url(#sp-dot)" filter="url(#sp-glow)">
<animate
v-if="active"
attributeName="r"
@@ -74,29 +74,27 @@ const wrapperStyle = computed(() => {
repeatCount="indefinite"
/>
</circle>
<circle v-if="active" cx="36" cy="13.5" r="1.8" fill="#F0D875" opacity="0.6">
<circle v-if="active" cx="36" cy="13.5" r="1.8" fill="#7CB9E8" 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="#D4AF37" opacity="0.4">
<circle v-if="active" cx="34" cy="34" r="1.5" fill="#005B9F" 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="#D4AF37" opacity="0.4">
<circle v-if="active" cx="14" cy="34" r="1.5" fill="#005B9F" 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(201, 162, 39, 0.05);
background: rgba(0, 102, 204, 0.04);
}
.header-title {
@@ -139,7 +139,7 @@ watch(totalPages, () => {
gap: 8px;
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
}
.announce-icon {
@@ -158,8 +158,8 @@ watch(totalPages, () => {
height: 24px;
border-radius: 4px;
border: 1px solid var(--border);
background: #1a1a1a;
color: var(--gold);
background: var(--bg-card);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
@@ -189,7 +189,7 @@ watch(totalPages, () => {
border: none;
background: transparent;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
@@ -198,22 +198,23 @@ watch(totalPages, () => {
}
.announce-row:hover {
background: rgba(255, 255, 255, 0.03);
background: var(--bg-hover);
}
.row-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.title {
flex: 1;
width: 100%;
font-size: 13px;
color: #fff;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -226,7 +227,7 @@ watch(totalPages, () => {
}
.chevron {
color: var(--gold);
color: var(--text-muted);
font-size: 18px;
font-family: monospace;
}

View File

@@ -3,9 +3,6 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import MatchBetCard from './MatchBetCard.vue';
import saishiImg from '../assets/images/saishi.webp';
import cardBg from '../assets/images/card-bg.webp';
const matchCardBg = `url(${cardBg})`;
const props = defineProps<{
leagueId: string;
@@ -88,11 +85,12 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
.league-block {
position: relative;
isolation: isolate;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
margin-bottom: 10px;
border: 1px solid #CBD5E1;
border-radius: 8px;
background: #FFFFFF;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.league-row {
@@ -114,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(0, 61, 107, 0.02) 0%, transparent 60%);
z-index: -1;
pointer-events: none;
}
@@ -129,17 +126,17 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
width: 26px;
height: 26px;
border-radius: 50%;
background: #141414;
border: 1px solid var(--border-gold-soft);
background: #003D6B;
border: none;
display: flex;
align-items: center;
justify-content: center;
}
.toggle-mark {
color: var(--primary-light);
color: #FFFFFF;
font-size: 17px;
font-weight: 900;
font-weight: 700;
line-height: 1;
margin-top: -1px;
}
@@ -154,8 +151,8 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
.league-title {
font-size: 13px;
font-weight: 800;
color: #fff;
font-weight: 700;
color: #1A1A2E;
line-height: 1.35;
min-width: 0;
}
@@ -165,16 +162,16 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
align-items: center;
gap: 6px;
font-size: 11px;
color: #ccc;
color: #6B7280;
line-height: 1.2;
}
.stat-live {
color: #3db865;
color: #059669;
}
.stat-open {
color: #c8a84e;
color: #D97706;
}
.league-saishi {
@@ -185,11 +182,13 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
object-fit: contain;
margin-left: 4px;
padding-left: 10px;
border-left: 1px solid #2a2a2a;
border-left: 1px solid #CBD5E1;
}
.match-panel {
padding: 10px 0 4px;
padding: 8px 0 4px;
background: #F0F2F6;
border-top: 1px solid #D1D5DB;
}
.match-grid {

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 rgba(255, 255, 255, 0.12);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
</style>

View File

@@ -80,16 +80,17 @@ onUnmounted(() => {
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #0d0d0d;
color: var(--primary-light);
background: #FFFFFF;
color: #003D6B;
font-size: 11px;
font-weight: 700;
font-family: inherit;
cursor: pointer;
box-sizing: border-box;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.locale-switch.compact .locale-trigger {
@@ -106,10 +107,10 @@ onUnmounted(() => {
margin: 0;
padding: 4px;
list-style: none;
background: #141414;
border: 1px solid var(--border-gold-soft);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: var(--shadow);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.locale-option {
@@ -118,7 +119,7 @@ onUnmounted(() => {
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
color: var(--text-muted);
color: #6B7280;
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -127,7 +128,7 @@ onUnmounted(() => {
.locale-option:hover,
.locale-option.active {
background: rgba(212, 175, 55, 0.1);
color: var(--primary-light);
background: rgba(0, 61, 107, 0.06);
color: #003D6B;
}
</style>

View File

@@ -55,7 +55,7 @@ function continueBrowsing() {
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
@@ -64,10 +64,11 @@ function continueBrowsing() {
position: relative;
width: 90%;
max-width: 360px;
background: #1a1a1a;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 12px;
padding: 28px 24px 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
}
.close-btn {
@@ -76,7 +77,7 @@ function continueBrowsing() {
right: 14px;
background: none;
border: none;
color: #666;
color: #6B7280;
font-size: 18px;
cursor: pointer;
padding: 4px;
@@ -84,20 +85,20 @@ function continueBrowsing() {
}
.close-btn:hover {
color: #aaa;
color: #1A1A2E;
}
.login-title {
font-size: 18px;
font-weight: 800;
color: var(--primary-light, #c8a84e);
color: #003D6B;
margin: 0 0 6px;
text-align: center;
}
.login-hint {
font-size: 12px;
color: #888;
color: #6B7280;
text-align: center;
margin: 0 0 24px;
}
@@ -112,8 +113,8 @@ function continueBrowsing() {
padding: 12px;
border-radius: 8px;
border: none;
background: linear-gradient(135deg, #d4a017, #e8c84a);
color: #1a1a1a;
background: #003D6B;
color: #FFFFFF;
font-size: 15px;
font-weight: 800;
cursor: pointer;
@@ -128,9 +129,9 @@ function continueBrowsing() {
.login-secondary {
padding: 10px;
border-radius: 8px;
border: 1px solid #333;
border: 1px solid #E5E7EB;
background: transparent;
color: #888;
color: #6B7280;
font-size: 14px;
font-weight: 600;
cursor: pointer;
@@ -139,8 +140,8 @@ function continueBrowsing() {
}
.login-secondary:active {
color: #aaa;
border-color: #444;
color: #1A1A2E;
border-color: #003D6B;
}
.fade-enter-active,

View File

@@ -1,9 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ref, computed, getCurrentInstance, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { teamFlagUrl } from '../utils/teamFlag';
import { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import { useBetSlipStore } from '../stores/betSlip';
import { useDesktopBetPopover } from '../composables/useDesktopBetPopover';
import { resolveSelectionLabel } from '../utils/selectionLabel';
import MarketSelectionsPanel from './match-detail/MarketSelectionsPanel.vue';
const props = defineProps<{
match: {
@@ -31,12 +36,21 @@ const props = defineProps<{
homeCards?: number | null;
awayCards?: number | null;
} | null;
markets?: any[];
};
noQuickBet?: boolean;
}>();
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, {
@@ -70,19 +84,149 @@ const liveScoreText = computed(() => {
if (!s || s.ftHome == null || s.ftAway == null) return '';
return `${s.ftHome} - ${s.ftAway}`;
});
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: { id: string }) => s.id === selId);
if (!sel) continue;
const code = (sel.selectionCode || '').toUpperCase();
if (code === 'HOME') return 'home';
if (code === 'AWAY') return 'away';
if (code === 'DRAW') return 'draw';
if (code === 'OVER') return 'home';
if (code === 'UNDER') return 'away';
if (code === 'ODD' || code === 'EVEN') return 'draw';
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';
if (selIndex === 1) return 'draw';
if (selIndex === 2) return 'away';
} else {
if (selIndex === 0) return 'home';
if (selIndex === 1) return 'away';
}
}
return '';
}
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(() => {
const available = pickQuickBetMarkets(props.match.markets);
if (activeMarketType.value && available.some((m) => m.marketType === activeMarketType.value)) {
return activeMarketType.value;
}
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: typeof props.match) {
return pickQuickBetMarkets(match.markets);
}
function getMarketTabLabel(type: string) {
const raw = (() => {
if (type === 'FT_1X2') return t('bet.market_ft_1x2');
if (type === 'FT_HANDICAP') return t('bet.market_ft_handicap');
if (type === 'FT_OVER_UNDER') return t('bet.market_ft_ou');
return type;
})();
return raw.replace(/全场\s*|FT\s*|Penuh\s*/gi, '').trim();
}
function getSelLabel(sel: { selectionCode?: string; selectionName: string }, _marketType: string, lineValue?: string | number | null) {
return resolveSelectionLabel(t, sel.selectionCode || '', sel.selectionName || '', { lineValue });
}
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();
if (status !== 'OPEN') return true;
return market.allowSingle !== true && market.allowParlay !== true;
}
function getActiveMarket() {
const type = currentMarketType.value;
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() {
router.push(`/match/${props.match.id}`);
}
</script>
<template>
<article
class="match-card"
:class="{ 'match-card--phase': phase !== 'open' }"
:data-bet-card-root="cardRootId"
:class="{
'match-card--phase': phase !== 'open',
'no-quick-bet': noQuickBet,
'match-card--engaged': isCardEngaged,
}"
@click="emit('bet', match.id)"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<span v-if="phase === 'open'" class="status-tag status-tag--open">{{ t('bet.status_open') }}</span>
<span v-else-if="phase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
<span v-else class="status-tag status-tag--pending">{{ phaseLabel }}</span>
<div class="teams-row">
<div class="teams-row" :class="getHoveredSide()">
<div class="team">
<span class="team-name">{{ match.homeTeamName }}</span>
<img
@@ -124,38 +268,73 @@ const liveScoreText = computed(() => {
>
{{ bettingOpen ? t('bet.place_bet_short') : t('bet.view_match') }}
</button>
<div v-if="!noQuickBet" class="card-quick-bet" @click.stop>
<div v-if="getAvailableMarketsForMatch(match).length" class="quick-bet-tabs">
<button
v-for="m in getAvailableMarketsForMatch(match)"
:key="m.marketType"
type="button"
class="quick-bet-tab-btn"
:class="{ active: currentMarketType === m.marketType }"
@click="activeMarketType = m.marketType"
>
{{ getMarketTabLabel(m.marketType) }}
</button>
<span class="quick-bet-all-link" @click="goMatchDetail">{{ t('bet.all') }} </span>
</div>
<div class="quick-bet-odds">
<template v-if="getActiveMarket()">
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(match, getActiveMarket())"
:line-value="getActiveMarket()?.lineValue"
: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') }}
</div>
</div>
</div>
</article>
</template>
<style scoped>
.match-card {
position: relative;
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
border: 1px solid rgba(140, 140, 140, 0.35);
border-radius: 6px;
padding: 10px 8px 10px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 130px;
overflow: hidden;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.match-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
background: linear-gradient(135deg, rgba(0, 61, 107, 0.03) 0%, transparent 50%, rgba(0, 91, 159, 0.03) 100%);
pointer-events: none;
z-index: 0;
}
.match-card--phase {
/* inherits base dark style */
}
.teams-row {
position: relative;
z-index: 2;
@@ -163,6 +342,7 @@ const liveScoreText = computed(() => {
align-items: center;
width: 100%;
gap: 4px;
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), padding 0.3s ease;
}
.team {
@@ -173,6 +353,7 @@ const liveScoreText = computed(() => {
gap: 6px;
min-width: 0;
transform: translateY(8px);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.team-name {
@@ -180,32 +361,33 @@ const liveScoreText = computed(() => {
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 900;
color: #fff;
font-size: 14px;
font-weight: 800;
color: var(--text);
line-height: 1.2;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.95);
word-break: break-word;
text-align: center;
padding: 0 6px;
padding: 0 4px;
align-self: stretch;
transition: font-size 0.25s ease;
}
.match-card--phase .team-name {
color: #d8d8d8;
color: #4b5563;
}
.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;
}
.team-flag.flag-logo {
width: 48px;
height: 48px;
width: 38px;
height: 38px;
object-fit: contain;
}
@@ -216,36 +398,27 @@ const liveScoreText = computed(() => {
align-items: center;
justify-content: center;
gap: 4px;
min-width: 56px;
min-width: 48px;
}
.kickoff {
font-size: 13px;
font-weight: 700;
color: #fff;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
line-height: 1.15;
white-space: normal;
text-align: center;
max-width: 96px;
max-width: 80px;
overflow-wrap: anywhere;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
}
.live-score {
font-size: 20px;
font-weight: 900;
color: #fff;
line-height: 1;
letter-spacing: 0.04em;
transition: opacity 0.2s ease, height 0.2s ease, margin 0.2s ease;
}
.live-score,
.vs {
font-size: 22px;
font-size: 18px;
font-weight: 900;
color: #fff;
letter-spacing: 0.08em;
color: var(--primary);
line-height: 1;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
}
.status-tag {
@@ -254,49 +427,222 @@ const liveScoreText = computed(() => {
right: 0;
z-index: 3;
font-size: 10px;
font-weight: 800;
padding: 3px 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 0 6px 0 8px;
line-height: 1.3;
white-space: nowrap;
letter-spacing: 0.02em;
}
.status-tag--open {
background: linear-gradient(135deg, #e8c84a, #d4a017);
color: #1a1a1a;
box-shadow: 0 2px 6px rgba(212, 160, 23, 0.4);
background: #fef3c7;
color: #92400e;
border-bottom: 1px solid #fde68a;
border-left: 1px solid #fde68a;
}
.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: #e8edf2;
color: #4b5563;
border-bottom: 1px solid #d1d5db;
border-left: 1px solid #d1d5db;
}
.status-tag--pending {
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
color: #c8a84e;
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
border-left: 1px solid rgba(200, 168, 78, 0.3);
background: #fef3c7;
color: #d97706;
border-bottom: 1px solid #fde68a;
border-left: 1px solid #fde68a;
}
.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;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.bet-btn--view {
background: #1a1a1a;
border: 1px solid #444;
color: #aaa;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
}
.card-quick-bet {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 0;
pointer-events: none;
transform: translateY(12px);
transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 5;
}
@media (hover: hover) {
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) {
border-color: var(--border-active);
box-shadow: 0 4px 12px rgba(0, 61, 107, 0.1);
transform: translateY(-1px);
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .teams-row {
transform: translateY(0);
padding: 0 28px;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team {
transform: translateY(0);
gap: 3px;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-flag {
width: 40px;
height: 28px;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-flag.flag-logo {
width: 28px;
height: 28px;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .team-name {
font-size: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .kickoff {
opacity: 0;
height: 0;
margin: 0;
overflow: hidden;
pointer-events: none;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .vs,
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .live-score {
font-size: 16px;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .bet-btn {
opacity: 0;
pointer-events: none;
}
.match-card:not(.no-quick-bet):is(:hover, .match-card--engaged) .card-quick-bet {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
.match-card.no-quick-bet:hover {
border-color: var(--border-active);
box-shadow: 0 4px 12px rgba(0, 61, 107, 0.1);
}
}
.quick-bet-tabs {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
margin-bottom: 2px;
}
.quick-bet-tab-btn {
padding: 2px 6px;
font-size: 9px;
font-weight: 700;
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
border-radius: 3px;
transition: all 0.15s;
}
.quick-bet-tab-btn:hover,
.quick-bet-tab-btn.active {
color: var(--primary-light);
background: rgba(0, 102, 204, 0.08);
}
.quick-bet-all-link {
margin-left: auto;
font-size: 9px;
font-weight: 700;
color: var(--primary-light);
cursor: pointer;
padding: 2px 4px;
}
.quick-bet-odds {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.quick-bet-odds :deep(.odds-btn) {
min-height: 26px !important;
padding: 1px 4px !important;
}
.quick-bet-odds :deep(.odds) {
font-size: 11px !important;
}
.quick-bet-odds :deep(.label) {
font-size: 8px !important;
}
.quick-bet-no-odds {
font-size: 10px;
color: var(--text-muted);
}
.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.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);
}
.teams-row.away .team:last-child {
transform: scale(1.08);
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);
}
.teams-row.draw .team {
transform: scale(1.05);
filter: brightness(1.08) saturate(1.1);
}
</style>

View File

@@ -195,11 +195,11 @@ onActivated(tryLoad);
}
.login-link {
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
background: rgba(0, 102, 204, 0.08);
color: var(--primary);
font-size: 13px;
font-weight: 600;
}
@@ -230,7 +230,7 @@ onActivated(tryLoad);
}
.list-row.unread .title {
color: #fff;
color: var(--text);
font-weight: 700;
}
@@ -243,8 +243,8 @@ onActivated(tryLoad);
}
.list-row.unread .row-dot {
background: var(--gold);
box-shadow: 0 0 8px rgba(212, 175, 55, 0.45);
background: var(--primary);
box-shadow: 0 0 8px rgba(0, 102, 204, 0.35);
}
.row-main {
@@ -266,7 +266,7 @@ onActivated(tryLoad);
flex: 1;
min-width: 0;
font-size: 15px;
color: #d8d8d8;
color: var(--text);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
@@ -279,13 +279,13 @@ onActivated(tryLoad);
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: #888;
background: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
background: var(--bg-hover);
}
.status-badge.unread {
color: var(--gold);
background: rgba(212, 175, 55, 0.12);
color: var(--primary);
background: rgba(0, 102, 204, 0.1);
}
.preview {
@@ -341,8 +341,8 @@ onActivated(tryLoad);
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #141414;
color: var(--gold);
background: var(--bg-hover);
color: var(--primary);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { useSmartBack } from '../composables/useSmartBack';
const props = withDefaults(
defineProps<{
fallback?: string;
variant?: 'desktop' | 'mobile';
showLabel?: boolean;
}>(),
{
variant: 'desktop',
showLabel: true,
},
);
const { t } = useI18n();
const { goBack } = useSmartBack(() => props.fallback);
</script>
<template>
<button
type="button"
class="page-back-btn"
:class="[`page-back-btn--${variant}`]"
:aria-label="t('common.back')"
@click="goBack"
>
<span class="page-back-icon" aria-hidden="true"></span>
<span v-if="showLabel && variant === 'desktop'" class="page-back-label">
{{ t('common.back') }}
</span>
</button>
</template>
<style scoped>
.page-back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
border: none;
background: none;
cursor: pointer;
flex-shrink: 0;
transition: color 0.2s;
}
.page-back-btn--desktop {
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
}
.page-back-btn--desktop:hover {
color: var(--text);
}
.page-back-btn--mobile {
color: #003d6b;
font-size: 24px;
line-height: 1;
padding: 0 8px;
}
.page-back-icon {
line-height: 1;
}
.page-back-btn--desktop .page-back-icon {
font-size: 18px;
}
</style>

View File

@@ -129,19 +129,20 @@ onUnmounted(() => {
gap: 3px;
height: 36px;
padding: 0 8px;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #0d0d0d;
color: var(--text);
background: #FFFFFF;
color: #1A1A2E;
font-family: inherit;
font-size: 12px;
line-height: 1;
cursor: pointer;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.country-trigger:active {
background: var(--bg-hover);
background: #F5F7FA;
}
.country-iso {
@@ -150,12 +151,12 @@ onUnmounted(() => {
}
.country-sep {
color: var(--text-muted);
color: #6B7280;
font-size: 10px;
}
.country-dial {
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
}
@@ -166,31 +167,31 @@ onUnmounted(() => {
z-index: 60;
width: min(260px, calc(100vw - 48px));
padding: 4px;
background: #141414;
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: var(--shadow);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.country-search {
width: 100%;
margin-bottom: 4px;
padding: 6px 8px;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 4px;
background: #0d0d0d;
color: var(--text);
background: #F5F7FA;
color: #1A1A2E;
font-size: 12px;
font-family: inherit;
}
.country-search::placeholder {
color: rgba(255, 255, 255, 0.35);
color: #6B7280;
}
.country-search:focus {
outline: none;
border-color: #555;
border-color: #003D6B;
}
.country-menu {
@@ -213,20 +214,20 @@ onUnmounted(() => {
.country-option:hover,
.country-option.active {
background: var(--bg-hover);
background: #F5F7FA;
}
.country-option .country-iso {
flex-shrink: 0;
width: 26px;
font-weight: 700;
color: var(--text);
color: #1A1A2E;
}
.country-option .country-dial {
flex-shrink: 0;
width: 38px;
color: var(--text-muted);
color: #6B7280;
}
.country-name {
@@ -234,17 +235,17 @@ onUnmounted(() => {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text-muted);
color: #6B7280;
}
.country-option:hover .country-name,
.country-option.active .country-name {
color: var(--text);
color: #1A1A2E;
}
.country-empty {
padding: 10px 8px;
color: var(--text-muted);
color: #6B7280;
font-size: 11px;
text-align: center;
}

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.35);
display: flex;
align-items: center;
justify-content: center;
@@ -69,11 +69,11 @@ function confirm() {
max-width: 360px;
max-height: 85vh;
overflow-y: auto;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft);
border-radius: var(--radius);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 12px;
padding: 16px 14px 14px;
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12);
}
.close-x {
@@ -83,9 +83,9 @@ function confirm() {
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.35);
color: var(--text-muted);
border: 1px solid #E5E7EB;
background: #F5F7FA;
color: #6B7280;
font-size: 12px;
line-height: 1;
padding: 0;
@@ -95,7 +95,7 @@ function confirm() {
margin: 0 28px 12px 0;
font-size: 15px;
font-weight: 800;
color: var(--primary-light);
color: #003D6B;
}
.actions {
@@ -114,12 +114,14 @@ function confirm() {
}
.btn-cancel {
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text-muted);
border: 1px solid #E5E7EB;
background: #FFFFFF;
color: #6B7280;
}
.btn-confirm {
border: none;
background: #003D6B;
color: #FFFFFF;
}
</style>

View File

@@ -78,7 +78,7 @@ function select(key: string) {
.picker-label {
display: block;
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
margin-bottom: 6px;
}
@@ -87,15 +87,15 @@ function select(key: string) {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text);
border: 1px solid #E5E7EB;
background: #F5F7FA;
color: #1A1A2E;
font-size: 13px;
}
.picker-search:focus {
outline: none;
border-color: var(--border-gold-soft);
border-color: #003D6B;
}
.picker-grid {
@@ -114,16 +114,16 @@ function select(key: string) {
gap: 4px;
padding: 8px 4px 6px;
border-radius: 8px;
border: 1px solid var(--border);
background: #0a0a0a;
border: 1px solid #E5E7EB;
background: #FFFFFF;
cursor: pointer;
min-width: 0;
}
.picker-item.active {
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.18);
background: rgba(212, 175, 55, 0.08);
border-color: #003D6B;
box-shadow: 0 0 0 1px rgba(0, 61, 107, 0.18);
background: rgba(0, 61, 107, 0.04);
}
.picker-photo {
@@ -132,14 +132,14 @@ 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 #E5E7EB;
}
.picker-name {
width: 100%;
font-size: 10px;
font-weight: 700;
color: var(--text);
color: #1A1A2E;
text-align: center;
line-height: 1.2;
overflow: hidden;
@@ -150,7 +150,7 @@ function select(key: string) {
.picker-meta {
width: 100%;
font-size: 9px;
color: var(--text-muted);
color: #6B7280;
text-align: center;
line-height: 1.2;
overflow: hidden;
@@ -161,7 +161,7 @@ function select(key: string) {
.picker-empty {
margin: 8px 0 0;
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
text-align: center;
}
</style>

View File

@@ -15,6 +15,23 @@ const CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const CANVAS_W = 96;
const CANVAS_H = 36;
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
function withAlpha(color: string, alpha: number): string {
const c = color.trim();
const rgb = c.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/);
if (rgb) return `rgba(${rgb[1]}, ${rgb[2]}, ${rgb[3]}, ${alpha})`;
const hex = c.match(/^#([0-9a-f]{6})$/i);
if (hex) {
const n = parseInt(hex[1], 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
}
return c;
}
function generateCode() {
let result = '';
for (let i = 0; i < 4; i++) {
@@ -31,18 +48,22 @@ function drawCaptcha() {
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.fillStyle = '#0d0d0d';
const bg = cssVar('--captcha-canvas-bg', '#0d0d0d');
const charBase = cssVar('--captcha-char', '#f0d875');
const noiseRgb = cssVar('--captcha-noise-rgb', '255, 255, 255');
ctx.fillStyle = bg;
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
for (let i = 0; i < 20; i++) {
ctx.fillStyle = `rgba(255, 255, 255, ${0.03 + Math.random() * 0.06})`;
ctx.fillStyle = `rgba(${noiseRgb}, ${0.03 + Math.random() * 0.06})`;
ctx.beginPath();
ctx.arc(Math.random() * CANVAS_W, Math.random() * CANVAS_H, Math.random() * 1.8, 0, Math.PI * 2);
ctx.fill();
}
for (let i = 0; i < 3; i++) {
ctx.strokeStyle = `rgba(255, 255, 255, ${0.06 + Math.random() * 0.08})`;
ctx.strokeStyle = `rgba(${noiseRgb}, ${0.06 + Math.random() * 0.08})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(Math.random() * CANVAS_W, Math.random() * CANVAS_H);
@@ -58,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 = `rgba(240, 216, 117, ${0.85 + Math.random() * 0.15})`;
ctx.fillStyle = withAlpha(charBase, 0.85 + Math.random() * 0.15);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(code.value[i], 0, 0);
@@ -139,14 +160,14 @@ defineExpose({ validate, refresh });
display: flex;
align-items: stretch;
height: 36px;
border: 1px solid var(--border);
border-radius: 6px;
background: #0d0d0d;
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
overflow: hidden;
}
.captcha-row:focus-within {
border-color: #555;
border-color: #003D6B;
}
.hp-field {
@@ -164,7 +185,7 @@ defineExpose({ validate, refresh });
padding: 0 10px;
border: none;
background: transparent;
color: var(--text);
color: #1A1A2E;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.12em;
@@ -174,7 +195,7 @@ defineExpose({ validate, refresh });
}
.captcha-input::placeholder {
color: rgba(255, 255, 255, 0.32);
color: #6B7280;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: none;
@@ -187,11 +208,11 @@ defineExpose({ validate, refresh });
cursor: pointer;
display: block;
border: none;
border-left: 1px solid var(--border);
border-left: 1px solid #E5E7EB;
}
.captcha-error {
color: var(--danger);
color: #DC2626;
font-size: 12px;
font-weight: 600;
margin: 4px 0 0;

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,26 +43,26 @@ withDefaults(
}
.status-watermark.won {
color: #3db865;
color: #059669;
}
.status-watermark.lost {
color: #e05050;
color: #DC2626;
}
.status-watermark.push {
color: #888;
color: #6B7280;
}
.status-watermark.pending {
color: #e8c84a;
color: #F8971F;
}
.status-watermark.closed {
color: #c9a84a;
color: #005B9F;
}
.status-watermark.settled {
color: #7a9ab8;
color: #0066CC;
}
</style>

View File

@@ -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 {

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: linear-gradient(145deg, #2a2210, #141008);
border: 2px solid #003D6B;
background: linear-gradient(145deg, #003D6B, #005B9F);
display: flex;
align-items: center;
justify-content: center;
@@ -106,7 +106,7 @@ function logout() {
.avatar-letter {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
color: #FFFFFF;
}
.avatar-menu {
@@ -115,10 +115,10 @@ function logout() {
right: 0;
min-width: 168px;
padding: 8px 0;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: #141414;
box-shadow: var(--shadow);
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 140;
}
@@ -126,8 +126,8 @@ function logout() {
padding: 8px 14px 10px;
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
border-bottom: 1px solid var(--border);
color: #003D6B;
border-bottom: 1px solid #E5E7EB;
margin-bottom: 4px;
}
@@ -136,22 +136,22 @@ function logout() {
text-align: left;
padding: 10px 14px;
background: none;
color: var(--text);
color: #1A1A2E;
font-size: 14px;
font-weight: 600;
}
.menu-item:hover {
background: rgba(255, 255, 255, 0.04);
background: #F5F7FA;
}
.menu-item.recharge {
color: var(--primary-light);
color: #003D6B;
font-weight: 700;
}
.menu-item.danger {
color: var(--danger);
color: #DC2626;
}
.backdrop {

View File

@@ -10,14 +10,14 @@ const isWalletSection = computed(
() => route.path.startsWith('/wallet') || route.path === '/profile/cashbacks',
);
const hubTitleKey = computed(() => (isWalletSection.value ? 'nav.wallet' : 'nav.bet_history'));
const hubTitleKey = computed(() => (isWalletSection.value ? 'nav.my_wallet' : 'nav.bet_history'));
const menuItems = computed(() => {
if (isWalletSection.value) {
return [
{
path: '/wallet',
labelKey: 'nav.wallet',
labelKey: 'nav.my_wallet',
match: (p: string) =>
p === '/wallet' ||
p === '/wallet/detail' ||
@@ -74,6 +74,7 @@ const menuItems = computed(() => {
.hub-nav-title {
font-size: 11px;
font-weight: 700;
font-family: var(--font-heading);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -89,17 +90,18 @@ const menuItems = computed(() => {
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
font-family: var(--font-heading);
transition: all 0.2s;
position: relative;
}
.nav-item:hover {
color: #fff;
color: var(--primary);
}
.nav-item.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
background: rgba(0, 102, 204, 0.08);
}
.nav-item.active .dot {
@@ -111,7 +113,7 @@ const menuItems = computed(() => {
width: 6px;
height: 6px;
border-radius: 50%;
background: #333;
background: var(--bg-hover);
transition: all 0.2s;
}

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter, RouterLink } from 'vue-router';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
@@ -26,6 +26,7 @@ const { refreshProfile } = usePlayerProfile();
const { requestLocate } = useDesktopBetLocate();
const activeTab = ref<PanelTab>('single');
const transitionName = ref('list-slide');
const historyScope = ref<'all' | 'match'>('all');
const historyItems = ref<BetHistoryItem[]>([]);
const historyTotal = ref(0);
@@ -75,6 +76,8 @@ const currentMatchId = computed(() => {
return outright ? outright[1] : '';
});
const isHomePage = computed(() => route.path === '/');
const showMatchHistoryFilter = computed(() => Boolean(currentMatchId.value));
const singleSlipItems = computed<SlipItem[]>(() => {
@@ -190,22 +193,32 @@ function genId() {
}
function selectTab(tab: PanelTab) {
transitionName.value = '';
activeTab.value = tab;
error.value = '';
if (tab === 'history') {
if (!auth.token) {
auth.showLoginPrompt(route.fullPath);
activeTab.value = slip.mode;
nextTick(() => {
transitionName.value = 'list-slide';
});
return;
}
if (showMatchHistoryFilter.value) {
historyScope.value = 'match';
}
void loadHistory(true);
nextTick(() => {
transitionName.value = 'list-slide';
});
return;
}
slip.setMode(tab);
syncStakeInputFromSlip();
nextTick(() => {
transitionName.value = 'list-slide';
});
}
function selectHistoryScope(scope: 'all' | 'match') {
@@ -805,7 +818,13 @@ watch(
watch(
() => slip.mode,
(mode) => {
if (activeTab.value !== 'history') activeTab.value = mode;
if (activeTab.value !== 'history') {
transitionName.value = '';
activeTab.value = mode;
nextTick(() => {
transitionName.value = 'list-slide';
});
}
},
);
@@ -911,7 +930,7 @@ onUnmounted(() => {
<p v-if="activeTab !== 'history' && oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<!-- Body -->
<div class="panel-body">
<div class="panel-body" :class="{ 'no-scrollbar': isHomePage }">
<template v-if="activeTab === 'history'">
<div v-if="historyInitialLoading" class="history-loading">
<GoldSpinner :size="28" />
@@ -969,7 +988,7 @@ onUnmounted(() => {
<p>{{ activeTab === 'parlay' ? t('bet.slip_parlay_empty_hint') : t('bet.slip_empty_hint') }}</p>
</div>
<div v-else class="items-list">
<TransitionGroup v-else :name="transitionName" tag="div" class="items-list">
<p v-if="activeTab === 'parlay' && parlayWarning" class="panel-warning">{{ parlayWarning }}</p>
<div
v-for="item in activeItems"
@@ -1073,7 +1092,7 @@ onUnmounted(() => {
<span>{{ t('bet.slip_parlay_count', { n: slip.parlayItems.length }) }}</span>
<span class="parlay-odds">{{ t('bet.slip_parlay_odds', { odds: totalOddsText }) }}</span>
</div>
</div>
</TransitionGroup>
<!-- Stake input (parlay only) -->
<div v-if="activeTab === 'parlay' && activeItems.length" class="stake-control">
@@ -1154,7 +1173,7 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
height: 100%;
background: rgba(16, 16, 16, 0.95);
background: var(--bg-card);
}
.panel-head {
@@ -1170,6 +1189,7 @@ onUnmounted(() => {
margin: 0;
font-size: 14px;
font-weight: 800;
font-family: var(--font-heading);
color: var(--primary-light);
}
@@ -1194,8 +1214,8 @@ onUnmounted(() => {
.clear-slip-btn:hover {
color: var(--primary-light);
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.06);
border-color: var(--border-active);
background: rgba(0, 102, 204, 0.06);
}
.balance-info {
@@ -1233,6 +1253,7 @@ onUnmounted(() => {
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
font-family: var(--font-heading);
padding: 10px 4px;
border-bottom: 2px solid transparent;
transition: all 0.2s;
@@ -1253,17 +1274,18 @@ onUnmounted(() => {
.badge {
background: var(--primary);
color: #111;
color: var(--bg-card);
font-size: 9px;
font-weight: 800;
font-family: var(--font-heading);
padding: 1px 4px;
border-radius: 6px;
line-height: 1;
}
.odds-warning {
background: rgba(212, 175, 55, 0.12);
border-bottom: 1px solid var(--border-gold-soft);
background: rgba(0, 102, 204, 0.12);
border-bottom: 1px solid var(--border-active);
color: var(--primary-light);
font-size: 11px;
font-weight: 600;
@@ -1305,7 +1327,7 @@ onUnmounted(() => {
}
.slip-card {
background: #1e1e1e;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 4px;
padding: 7px 8px;
@@ -1314,7 +1336,7 @@ onUnmounted(() => {
}
.slip-card:hover {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
}
.card-top {
@@ -1336,6 +1358,7 @@ onUnmounted(() => {
.match-name {
font-size: 10px;
font-weight: 700;
font-family: var(--font-heading);
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
@@ -1398,7 +1421,8 @@ onUnmounted(() => {
.selection-name {
font-size: 11px;
font-weight: 700;
color: #fff;
font-family: var(--font-heading);
color: var(--text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -1429,7 +1453,7 @@ onUnmounted(() => {
}
.odds-suspended {
background: rgba(255, 255, 255, 0.05);
background: rgba(0, 0, 0, 0.04);
color: var(--text-muted);
text-decoration: line-through;
}
@@ -1437,7 +1461,7 @@ onUnmounted(() => {
.card-stake-block {
margin-top: 5px;
padding-top: 5px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
border-top: 1px solid rgba(0, 0, 0, 0.04);
display: flex;
flex-direction: column;
gap: 4px;
@@ -1453,7 +1477,7 @@ onUnmounted(() => {
flex: 1;
display: flex;
align-items: center;
background: #0d0d0d;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 3px;
padding: 0 6px;
@@ -1465,7 +1489,7 @@ onUnmounted(() => {
flex: 1;
background: transparent;
border: none !important;
color: #fff;
color: var(--text);
padding: 0;
font-size: 11px;
font-weight: 700;
@@ -1518,39 +1542,39 @@ onUnmounted(() => {
}
.card-single-btn {
border: 1px solid rgba(212, 175, 55, 0.55);
background: rgba(212, 175, 55, 0.14);
border: 1px solid rgba(0, 102, 204, 0.55);
background: rgba(0, 102, 204, 0.14);
color: var(--primary-light);
}
.card-single-btn:hover:not(:disabled) {
background: rgba(212, 175, 55, 0.22);
border-color: var(--border-gold);
color: #fff;
background: rgba(0, 102, 204, 0.22);
border-color: var(--border-active);
color: var(--text);
}
.card-parlay-btn {
border: 1px dashed rgba(212, 175, 55, 0.45);
background: rgba(212, 175, 55, 0.04);
border: 1px dashed rgba(0, 102, 204, 0.45);
background: rgba(0, 102, 204, 0.04);
color: var(--primary-light);
}
.card-parlay-btn:hover:not(:disabled) {
background: rgba(212, 175, 55, 0.1);
border-color: var(--border-gold-soft);
color: #fff;
background: rgba(0, 102, 204, 0.1);
border-color: var(--border-active);
color: var(--text);
}
.card-parlay-btn.active {
border-style: solid;
border-color: rgba(212, 175, 55, 0.35);
background: rgba(255, 255, 255, 0.03);
border-color: rgba(0, 102, 204, 0.35);
background: rgba(0, 0, 0, 0.04);
color: var(--text-muted);
}
.card-parlay-btn.active:hover {
color: var(--primary-light);
border-color: var(--border-gold-soft);
border-color: var(--border-active);
}
.card-parlay-btn-icon {
@@ -1560,7 +1584,7 @@ onUnmounted(() => {
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(212, 175, 55, 0.16);
background: rgba(0, 102, 204, 0.16);
font-size: 11px;
line-height: 1;
font-weight: 800;
@@ -1590,8 +1614,8 @@ onUnmounted(() => {
.panel-warning {
font-size: 11px;
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--border-gold-soft);
background: rgba(0, 102, 204, 0.08);
border: 1px solid var(--border-active);
border-radius: 4px;
padding: 8px;
margin: 6px 0;
@@ -1604,7 +1628,7 @@ onUnmounted(() => {
font-weight: 700;
color: var(--text-muted);
padding: 4px 6px;
background: #141414;
background: var(--bg-body);
border-radius: 4px;
}
@@ -1613,7 +1637,7 @@ onUnmounted(() => {
}
.stake-control {
background: #141414;
background: var(--bg-body);
border-radius: 4px;
padding: 10px;
display: flex;
@@ -1624,7 +1648,7 @@ onUnmounted(() => {
.stake-input-box {
display: flex;
align-items: center;
background: #0d0d0d;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0 10px;
@@ -1642,7 +1666,7 @@ onUnmounted(() => {
flex: 1;
background: transparent;
border: none !important;
color: #fff;
color: var(--text);
padding: 0;
font-size: 14px;
font-weight: 700;
@@ -1668,7 +1692,7 @@ onUnmounted(() => {
.chip-btn {
flex: 1;
background: #1e1e1e;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
@@ -1679,8 +1703,8 @@ onUnmounted(() => {
}
.chip-btn:hover {
border-color: var(--border-gold-soft);
color: #fff;
border-color: var(--border-active);
color: var(--text);
}
.panel-error-msg {
@@ -1700,7 +1724,7 @@ onUnmounted(() => {
.panel-bottom-fixed {
flex-shrink: 0;
border-top: 1px solid var(--border);
background: rgba(16, 16, 16, 0.98);
background: var(--bg-card);
}
.panel-summary {
@@ -1744,16 +1768,17 @@ onUnmounted(() => {
width: 100%;
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
background: transparent;
color: var(--primary-light);
font-size: 11px;
font-weight: 700;
font-family: var(--font-heading);
transition: all 0.2s;
}
.add-parlay-btn:hover {
background: rgba(212, 175, 55, 0.05);
background: rgba(0, 102, 204, 0.05);
border-color: var(--primary);
}
@@ -1762,10 +1787,11 @@ onUnmounted(() => {
min-width: 0;
padding: 10px;
border-radius: 4px;
background: linear-gradient(180deg, #F0D875 0%, #D4AF37 100%);
color: #3D2800;
background: linear-gradient(180deg, var(--primary-light) 0%, var(--primary) 100%);
color: #FFFFFF;
font-size: 13px;
font-weight: 800;
font-family: var(--font-heading);
text-transform: uppercase;
transition: opacity 0.2s;
border: none;
@@ -1783,8 +1809,8 @@ onUnmounted(() => {
width: 100%;
padding: 10px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--border-active);
background: rgba(0, 102, 204, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 800;
@@ -1794,7 +1820,7 @@ onUnmounted(() => {
}
.history-view-all-btn:hover {
background: rgba(212, 175, 55, 0.14);
background: rgba(0, 102, 204, 0.14);
border-color: var(--primary);
}
@@ -1818,13 +1844,14 @@ onUnmounted(() => {
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
font-family: var(--font-heading);
transition: all 0.15s;
}
.scope-chip.active {
color: var(--primary-light);
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
border-color: var(--border-active);
background: rgba(0, 102, 204, 0.08);
}
.history-loading {
@@ -1844,15 +1871,15 @@ onUnmounted(() => {
padding: 8px;
border-radius: 4px;
border: 1px solid var(--border);
background: #1e1e1e;
background: var(--bg-body);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, background 0.15s;
}
.history-card:hover {
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.04);
border-color: var(--border-active);
background: rgba(0, 102, 204, 0.04);
}
.history-card-top {
@@ -1866,7 +1893,8 @@ onUnmounted(() => {
.history-pick {
font-size: 11px;
font-weight: 700;
color: #fff;
font-family: var(--font-heading);
color: var(--text);
line-height: 1.35;
min-width: 0;
}
@@ -1875,6 +1903,7 @@ onUnmounted(() => {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
font-family: var(--font-heading);
padding: 1px 5px;
border-radius: 3px;
}
@@ -1896,11 +1925,12 @@ onUnmounted(() => {
.history-status.hist-pending {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
background: rgba(0, 102, 204, 0.1);
}
.history-match {
font-size: 10px;
font-family: var(--font-heading);
color: var(--text-muted);
margin-bottom: 4px;
overflow: hidden;
@@ -1964,4 +1994,23 @@ onUnmounted(() => {
opacity: 0.5;
cursor: not-allowed;
}
.list-slide-enter-active,
.list-slide-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.list-slide-enter-from {
opacity: 0;
transform: translateY(-20px) scale(0.95);
}
.list-slide-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
.list-slide-move {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>

View File

@@ -60,7 +60,7 @@ const { t } = useI18n();
.data-table-container {
width: 100%;
overflow-x: auto;
background: rgba(20, 20, 20, 0.95);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
}
@@ -73,7 +73,7 @@ const { t } = useI18n();
}
th {
background: #141414;
background: var(--bg-body);
color: var(--text-muted);
font-weight: 800;
text-transform: uppercase;

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
/**
* PC auth shell — admin-style card: large logo left, form slot right (theme-2).
*/
import LocaleSwitcher from '../LocaleSwitcher.vue';
withDefaults(
defineProps<{
wide?: boolean;
}>(),
{ wide: false },
);
</script>
<template>
<div class="desktop-auth-page">
<div class="ambient-glow ambient-glow--1" aria-hidden="true"></div>
<div class="ambient-glow ambient-glow--2" aria-hidden="true"></div>
<div class="auth-wrap">
<div class="auth-card" :class="{ 'auth-card--wide': wide }">
<div class="form-lang">
<LocaleSwitcher compact />
</div>
<div class="form-body">
<aside class="form-brand">
<img src="/logo.png" alt="TheBet365" class="logo-large" />
</aside>
<div class="form-fields">
<slot />
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.desktop-auth-page {
position: relative;
min-height: 100dvh;
background: linear-gradient(160deg, #f5f7fa 0%, #e8edf2 55%, #f5f7fa 100%);
overflow: hidden;
}
.desktop-auth-page::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 70% 60% at 50% 20%, rgba(0, 102, 204, 0.08) 0%, transparent 70%);
pointer-events: none;
}
.ambient-glow {
position: absolute;
border-radius: 50%;
filter: blur(70px);
pointer-events: none;
opacity: 0.35;
z-index: 0;
}
.ambient-glow--1 {
width: 360px;
height: 360px;
top: 8%;
left: 10%;
background: radial-gradient(circle, rgba(0, 91, 159, 0.22) 0%, transparent 70%);
}
.ambient-glow--2 {
width: 320px;
height: 320px;
bottom: 10%;
right: 8%;
background: radial-gradient(circle, rgba(0, 102, 204, 0.16) 0%, transparent 70%);
}
.auth-wrap {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: 24px 20px;
overflow-y: auto;
}
.auth-card {
width: 100%;
max-width: 720px;
background: var(--glass-bg-strong);
backdrop-filter: var(--blur-lg);
-webkit-backdrop-filter: var(--blur-lg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg), 0 0 40px rgba(0, 102, 204, 0.06);
overflow: hidden;
}
.auth-card--wide {
max-width: 820px;
}
.form-lang {
display: flex;
justify-content: flex-end;
padding: 14px 18px 0;
}
.form-body {
display: grid;
grid-template-columns: minmax(200px, 260px) 1fr;
align-items: stretch;
}
.form-brand {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-9) var(--space-8);
background: linear-gradient(180deg, rgba(0, 102, 204, 0.05) 0%, rgba(232, 237, 242, 0.65) 100%);
border-right: 1px solid var(--border);
}
.logo-large {
width: 100%;
max-width: 220px;
height: auto;
object-fit: contain;
filter: drop-shadow(0 6px 20px rgba(0, 91, 159, 0.18));
}
.form-fields {
display: flex;
flex-direction: column;
min-width: 0;
padding: 22px 24px 24px;
}
@media (max-width: 640px) {
.auth-wrap {
align-items: flex-start;
padding: 18px;
}
.auth-card,
.auth-card--wide {
max-width: 420px;
}
.form-body {
grid-template-columns: 1fr;
}
.form-brand {
padding: 20px 24px 12px;
border-right: none;
border-bottom: 1px solid var(--border);
}
.logo-large {
max-width: 180px;
}
.form-fields {
padding: 18px 20px 22px;
}
}
</style>

View File

@@ -10,6 +10,8 @@ const props = defineProps<{
fallbackTo?: string;
}>();
const AUTOPLAY_MS = 5500;
const { t } = useI18n();
const router = useRouter();
const active = ref(0);
@@ -18,6 +20,7 @@ const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const slideCount = computed(() => props.banners.length);
const canLoop = computed(() => slideCount.value > 1);
const activeBanner = computed(() => props.banners[active.value]);
function imageUrl(banner: BannerItem) {
return banner.translation?.imageUrl || defaultBannerImg || FALLBACK_IMG;
@@ -34,61 +37,6 @@ function title(banner: BannerItem) {
return banner.translation?.title || t('home.banner_fallback');
}
function normalizeOffset(index: number) {
const len = slideCount.value;
if (!len) return 0;
let offset = index - active.value;
if (offset > len / 2) offset -= len;
if (offset < -len / 2) offset += len;
return offset;
}
/** Coverflow略重叠中间卡片置顶 */
function slideLayout(index: number) {
const offset = normalizeOffset(index);
const abs = Math.abs(offset);
if (abs > 1) {
return { hidden: true, offset, active: false };
}
const slotX = 50 + offset * 26;
const scale = offset === 0 ? 1 : 0.9;
const rotateY = offset === 0 ? 0 : offset < 0 ? 12 : -12;
const zIndex = offset === 0 ? 10 : 5;
return {
hidden: false,
offset,
active: offset === 0,
slotX,
scale,
rotateY,
zIndex,
};
}
function slideStyle(index: number) {
const layout = slideLayout(index);
if (layout.hidden) {
return {
left: '50%',
transform: 'translate(-50%, -50%) scale(0.7)',
opacity: '0',
zIndex: '0',
pointerEvents: 'none' as const,
};
}
return {
left: `${layout.slotX}%`,
transform: `translate(-50%, -50%) scale(${layout.scale}) rotateY(${layout.rotateY}deg)`,
opacity: '1',
zIndex: String(layout.zIndex),
pointerEvents: 'auto' as const,
};
}
function goTo(index: number) {
if (!slideCount.value) return;
active.value = (index + slideCount.value) % slideCount.value;
@@ -102,11 +50,7 @@ function prev() {
goTo(active.value - 1);
}
function onBannerClick(banner: BannerItem, index: number) {
if (normalizeOffset(index) !== 0) {
goTo(index);
return;
}
function onBannerClick(banner: BannerItem) {
if (banner.id) {
void router.push(`/announcements/${banner.id}`);
return;
@@ -119,7 +63,7 @@ function onBannerClick(banner: BannerItem, index: number) {
function startAutoPlay() {
stopAutoPlay();
if (!canLoop.value) return;
timer.value = setInterval(next, 5000);
timer.value = setInterval(next, AUTOPLAY_MS);
}
function stopAutoPlay() {
@@ -144,46 +88,54 @@ onUnmounted(stopAutoPlay);
<template>
<section
v-if="banners.length"
class="banner-coverflow"
class="hero-carousel"
@mouseenter="stopAutoPlay"
@mouseleave="startAutoPlay"
>
<div class="coverflow-stage">
<button
v-for="(banner, i) in banners"
:key="banner.id ?? i"
type="button"
class="coverflow-slide"
:class="{ 'is-active': slideLayout(i).active, 'is-side': Math.abs(slideLayout(i).offset) === 1 }"
:style="slideStyle(i)"
:aria-label="title(banner)"
@click="onBannerClick(banner, i)"
>
<div class="slide-card">
<div class="hero-frame">
<div class="hero-stage">
<button
v-for="(banner, i) in banners"
:key="banner.id ?? i"
type="button"
class="hero-slide"
:class="{ 'is-active': i === active }"
:aria-label="title(banner)"
@click="onBannerClick(banner)"
>
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="''"
class="slide-img"
alt=""
class="hero-backdrop"
aria-hidden="true"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
<div v-else class="slide-fallback" aria-hidden="true">
<svg class="fallback-icon" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="title(banner)"
class="hero-img"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
<div v-else class="hero-fallback">
<span>{{ title(banner) }}</span>
</div>
<div v-if="slideLayout(i).active" class="click-hint" aria-hidden="true">
<span class="click-ring" />
<svg class="click-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M9 11.24V7.5a2.5 2.5 0 0 1 5 0v3.74c1.21-.81 2-2.18 2-3.74C16 5.01 13.99 3 11.5 3S7 5.01 7 7.5c0 1.56.79 2.93 2 3.74zm9.84 4.63-4.54-2.26a1.5 1.5 0 0 0-.54-.11H13v-6c0-.83-.67-1.5-1.5-1.5S10 6.67 10 7.5v10.74l-3.43-.72a1 1 0 0 0-.24-.03c-.31 0-.59.13-.79.33l-.79.8 4.94 4.94c.27.27.65.44 1.06.44h6.79c.75 0 1.33-.55 1.44-1.28l.75-5.27a1.5 1.5 0 0 0-.91-1.38z"
/>
</svg>
<div class="hero-shade" aria-hidden="true" />
<div v-if="i === active" class="hero-caption">
<span class="hero-badge">{{ t('home.announcement_badge') || '公告' }}</span>
<h3 class="hero-title">{{ title(banner) }}</h3>
<span class="hero-link">
{{ t('announcements.go_link') }}
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</span>
</div>
</div>
</button>
</button>
</div>
<button
v-if="canLoop"
@@ -203,109 +155,89 @@ onUnmounted(stopAutoPlay);
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
<div v-if="canLoop" class="hero-footer">
<div class="hero-dots">
<button
v-for="(_, i) in banners"
:key="i"
type="button"
class="dot"
:class="{ active: i === active }"
:aria-label="t('home.banner_slide', { n: i + 1 })"
@click.stop="goTo(i)"
/>
</div>
<div class="hero-progress" aria-hidden="true">
<div :key="active" class="hero-progress-fill" />
</div>
</div>
</div>
<div v-if="canLoop" class="coverflow-dots">
<button
v-for="(_, i) in banners"
:key="i"
type="button"
class="dot"
:class="{ active: i === active }"
:aria-label="t('home.banner_slide', { n: i + 1 })"
@click.stop="goTo(i)"
/>
<div v-if="activeBanner && canLoop" class="hero-counter" aria-hidden="true">
{{ String(active + 1).padStart(2, '0') }} / {{ String(slideCount).padStart(2, '0') }}
</div>
</section>
</template>
<style scoped>
.banner-coverflow {
width: 100%;
padding: 4px 0 0;
}
.coverflow-stage {
.hero-carousel {
position: relative;
height: clamp(260px, 28vw, 360px);
perspective: 1000px;
width: 100%;
}
.coverflow-slide {
.hero-frame {
position: relative;
border-radius: 14px;
overflow: hidden;
border: 1px solid var(--border);
background: linear-gradient(135deg, #EEF4FA 0%, #FFFFFF 55%, #F5F7FA 100%);
box-shadow: var(--shadow);
}
.hero-stage {
position: relative;
height: clamp(220px, 24vw, 300px);
}
.hero-slide {
position: absolute;
top: 50%;
width: 42%;
max-width: 560px;
height: 90%;
inset: 0;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
opacity: 0;
transform: scale(1.015);
transition:
left 0.55s cubic-bezier(0.25, 0.8, 0.25, 1),
transform 0.55s cubic-bezier(0.25, 0.8, 0.25, 1),
opacity 0.4s ease;
transform-style: preserve-3d;
will-change: left, transform;
}
.slide-card {
position: relative;
width: 100%;
height: 100%;
border-radius: 14px;
opacity 0.65s ease,
transform 0.65s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
overflow: hidden;
background: linear-gradient(180deg, #141414 0%, #0a0a0a 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;
align-items: center;
justify-content: center;
}
.coverflow-slide.is-side .slide-card {
filter: brightness(0.72) saturate(0.9);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
.hero-slide.is-active {
opacity: 1;
transform: scale(1);
pointer-events: auto;
z-index: 2;
}
.coverflow-slide.is-side:hover .slide-card {
filter: brightness(0.88) saturate(1);
.hero-backdrop {
position: absolute;
inset: -24px;
width: calc(100% + 48px);
height: calc(100% + 48px);
object-fit: cover;
filter: blur(22px) saturate(1.1);
opacity: 0.42;
transform: scale(1.06);
pointer-events: none;
}
.coverflow-slide.is-active .slide-card {
filter: none;
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 28px rgba(212, 175, 55, 0.22),
0 0 56px rgba(212, 175, 55, 0.1);
animation: banner-breathe 3s ease-in-out infinite;
}
.coverflow-slide.is-active:hover .slide-card {
animation: none;
transform: scale(1.018);
}
@keyframes banner-breathe {
0%,
100% {
transform: scale(1);
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 22px rgba(212, 175, 55, 0.16),
0 0 48px rgba(212, 175, 55, 0.08);
}
50% {
transform: scale(1.008);
box-shadow:
0 18px 44px rgba(0, 0, 0, 0.52),
0 0 32px rgba(212, 175, 55, 0.26),
0 0 60px rgba(212, 175, 55, 0.12);
}
}
.slide-img {
.hero-img {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
object-fit: contain;
@@ -313,111 +245,123 @@ onUnmounted(stopAutoPlay);
display: block;
}
.slide-fallback {
.hero-fallback {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.12), #1a1a1a);
background: linear-gradient(135deg, rgba(0, 61, 107, 0.08), rgba(0, 102, 204, 0.04));
font-size: clamp(18px, 2.2vw, 26px);
font-weight: 800;
color: var(--primary);
letter-spacing: 0.02em;
}
.fallback-icon {
width: 40px;
height: 40px;
color: rgba(212, 175, 55, 0.35);
}
.click-hint {
position: absolute;
right: 10px;
bottom: 10px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
background: rgba(0, 0, 0, 0.35);
border-radius: 50%;
}
.click-ring {
.hero-shade {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(212, 175, 55, 0.45);
animation: click-ring-pulse 2.8s ease-out infinite;
z-index: 2;
background:
linear-gradient(180deg, rgba(0, 45, 82, 0.04) 0%, transparent 38%),
linear-gradient(0deg, rgba(0, 36, 71, 0.72) 0%, rgba(0, 36, 71, 0.28) 42%, transparent 72%);
pointer-events: none;
}
.click-icon {
position: relative;
width: 15px;
height: 15px;
color: rgba(212, 175, 55, 0.88);
animation: click-icon-bob 2.8s ease-in-out infinite;
.hero-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
padding: 20px 24px 18px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
text-align: left;
animation: caption-in 0.55s ease both;
}
@keyframes click-ring-pulse {
0% {
transform: scale(0.9);
opacity: 0.55;
}
70% {
transform: scale(1.35);
@keyframes caption-in {
from {
opacity: 0;
transform: translateY(10px);
}
100% {
transform: scale(1.35);
opacity: 0;
}
}
@keyframes click-icon-bob {
0%,
100% {
transform: translateY(1px) scale(1);
opacity: 0.65;
}
50% {
transform: translateY(-1px) scale(1.06);
to {
opacity: 1;
transform: translateY(0);
}
}
.coverflow-slide.is-active:hover .click-hint {
opacity: 0;
transition: opacity 0.2s ease;
.hero-badge {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.28);
color: rgba(255, 255, 255, 0.92);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
backdrop-filter: blur(6px);
}
@media (prefers-reduced-motion: reduce) {
.coverflow-slide.is-active .slide-card,
.click-ring,
.click-icon {
animation: none;
}
.hero-title {
margin: 0;
max-width: min(72%, 560px);
font-size: clamp(16px, 1.8vw, 22px);
font-weight: 800;
line-height: 1.35;
color: #fff;
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.25);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.hero-link {
display: inline-flex;
align-items: center;
gap: 2px;
margin-top: 2px;
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.88);
transition: color 0.2s ease, transform 0.2s ease;
}
.hero-link svg {
width: 16px;
height: 16px;
}
.hero-slide:hover .hero-link {
color: #fff;
transform: translateX(2px);
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
width: 38px;
height: 38px;
border-radius: 50%;
border: 1px solid rgba(212, 175, 55, 0.4);
background: rgba(10, 10, 10, 0.75);
color: var(--primary-light);
border: 1px solid rgba(255, 255, 255, 0.65);
background: rgba(255, 255, 255, 0.88);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
transition: background 0.2s, border-color 0.2s;
z-index: 10;
box-shadow: 0 4px 14px rgba(0, 61, 107, 0.12);
transition: background 0.2s, border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.nav-btn svg {
@@ -426,30 +370,42 @@ onUnmounted(stopAutoPlay);
}
.nav-btn:hover {
background: rgba(212, 175, 55, 0.2);
border-color: var(--primary);
background: #fff;
border-color: #fff;
transform: translateY(-50%) scale(1.05);
box-shadow: 0 6px 18px rgba(0, 61, 107, 0.16);
}
.nav-btn.prev {
left: 4px;
left: 14px;
}
.nav-btn.next {
right: 4px;
right: 14px;
}
.coverflow-dots {
.hero-footer {
position: absolute;
left: 0;
right: 0;
bottom: 0;
z-index: 8;
pointer-events: none;
}
.hero-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 14px;
gap: 6px;
padding: 0 0 10px;
pointer-events: auto;
}
.dot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
background: rgba(255, 255, 255, 0.45);
border: none;
padding: 0;
cursor: pointer;
@@ -457,12 +413,64 @@ onUnmounted(stopAutoPlay);
}
.dot.active {
width: 22px;
border-radius: 4px;
background: var(--gradient-gold);
width: 20px;
border-radius: 3px;
background: #fff;
}
.dot:hover:not(.active) {
background: rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.72);
}
.hero-progress {
height: 3px;
background: rgba(255, 255, 255, 0.18);
}
.hero-progress-fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.55), #fff);
animation: hero-progress 5.5s linear forwards;
}
@keyframes hero-progress {
from {
width: 0%;
}
to {
width: 100%;
}
}
.hero-counter {
position: absolute;
top: 14px;
right: 14px;
z-index: 10;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(0, 61, 107, 0.1);
color: var(--primary);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.06em;
backdrop-filter: blur(8px);
box-shadow: 0 2px 8px rgba(0, 61, 107, 0.08);
}
@media (prefers-reduced-motion: reduce) {
.hero-slide,
.hero-caption,
.hero-progress-fill {
animation: none;
transition: none;
}
.hero-progress-fill {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,502 @@
<script setup lang="ts">
import { ref, watch, computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import GoldSpinner from '../GoldSpinner.vue';
import { formatMoney } from '../../utils/localeDisplay';
import type { BetHistoryItem } from '../BetHistoryCard.vue';
const props = defineProps<{
visible: boolean;
betNo: string | null;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
}>();
const { t, locale } = useI18n();
const detail = ref<BetHistoryItem | null>(null);
const loading = ref(false);
const error = ref(false);
async function loadDetail(betNo: string) {
loading.value = true;
error.value = false;
detail.value = null;
try {
const { data } = await api.get(`/player/bets/${betNo}`);
detail.value = data.data ?? null;
} catch {
error.value = true;
} finally {
loading.value = false;
}
}
watch(
() => [props.visible, props.betNo] as const,
([vis, no]) => {
if (vis && no) void loadDetail(no);
if (!vis) {
detail.value = null;
error.value = false;
loading.value = false;
}
},
{ immediate: true },
);
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));
function statusClass(status: string) {
const map: Record<string, string> = {
WON: 'st-won',
LOST: 'st-lost',
PENDING: 'st-pending',
PUSH: 'st-push',
CANCELLED: 'st-cancelled',
};
return map[status?.toUpperCase()] ?? 'st-default';
}
function statusLabel(status: string) {
const map: Record<string, string> = {
WON: t('history.filter_won'),
LOST: t('history.filter_lost'),
PENDING: t('history.filter_pending'),
PUSH: t('history.filter_push'),
CANCELLED: t('common.cancelled'),
};
return map[status?.toUpperCase()] ?? status;
}
const SEL_TRANS: Record<string, Record<string, string>> = {
'主胜': { 'en-US': 'Home Win', 'ms-MY': 'Rumah Menang' },
'客胜': { 'en-US': 'Away Win', 'ms-MY': 'Tandang Menang' },
'和局': { 'en-US': 'Draw', 'ms-MY': 'Seri' },
'主': { 'en-US': 'Home', 'ms-MY': 'Rumah' },
'客': { 'en-US': 'Away', 'ms-MY': 'Tandang' },
'大': { 'en-US': 'Over', 'ms-MY': 'Atas' },
'小': { 'en-US': 'Under', 'ms-MY': 'Bawah' },
'单': { 'en-US': 'Odd', 'ms-MY': 'Ganjil' },
'双': { 'en-US': 'Even', 'ms-MY': 'Genap' },
'冠军': { 'en-US': 'Winner', 'ms-MY': 'Juara' },
};
function translateSel(name: string): string {
if (locale.value === 'zh-CN') return name;
const exact = SEL_TRANS[name];
if (exact) return exact[locale.value] ?? exact['en-US'] ?? name;
const sp = name.indexOf(' ');
if (sp > 0) {
const head = name.slice(0, sp);
const m = SEL_TRANS[head];
if (m) return (m[locale.value] ?? m['en-US'] ?? head) + name.slice(sp);
}
return name;
}
function legResultIcon(status?: string | null) {
switch (status?.toUpperCase()) {
case 'WON': return '✓';
case 'LOST': return '✗';
case 'PUSH': return '=';
default: return '';
}
}
function legResultClass(status?: string | null) {
switch (status?.toUpperCase()) {
case 'WON': return 'leg-won';
case 'LOST': return 'leg-lost';
case 'PUSH': return 'leg-push';
default: return 'leg-pending';
}
}
const displayLegs = computed(() => {
if (!detail.value) return [];
if (detail.value.legs?.length) return detail.value.legs;
if (detail.value.isParlay) return [];
return [{
matchTitle: detail.value.matchTitle,
marketLabel: '',
selectionName: detail.value.pickLabel,
odds: detail.value.totalOdds,
resultStatus: detail.value.status,
score: detail.value.matchScore ?? null,
}];
});
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal-container" role="dialog" aria-modal="true" :aria-label="t('bet.detail_title')">
<div class="modal-header">
<span class="modal-title">{{ t('bet.detail_title') }}</span>
<button type="button" class="modal-close" :aria-label="t('support.close')" @click="close"></button>
</div>
<div class="modal-body">
<div v-if="loading" class="modal-loading">
<GoldSpinner :size="32" />
</div>
<div v-else-if="error" class="modal-error">
<p>{{ t('common.load_failed') }}</p>
<button v-if="betNo" type="button" class="retry-btn" @click="loadDetail(betNo)">
{{ t('common.retry') }}
</button>
</div>
<template v-else-if="detail">
<div class="summary-strip">
<div class="summary-main">
<span class="bet-no mono">{{ detail.betNo }}</span>
<span class="st-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
</div>
<div class="summary-metrics">
<div class="metric">
<span class="metric-label">{{ t('bet.stake') }}</span>
<span class="metric-val">{{ formatMoney(detail.stake, locale) }}</span>
</div>
<div class="metric">
<span class="metric-label">{{ t('bet.potential_payout') }}</span>
<span class="metric-val accent">{{ formatMoney(detail.potentialReturn, locale) }}</span>
</div>
<div class="metric">
<span class="metric-label">{{ t('bet.placed_at') }}</span>
<span class="metric-val muted">
{{ detail.placedAt ? new Date(detail.placedAt).toLocaleString() : '-' }}
</span>
</div>
</div>
</div>
<div class="sel-section">
<div class="sel-section-title">{{ t('bet.selections') }}</div>
<div v-for="(leg, idx) in displayLegs" :key="idx" class="sel-item">
<div class="sel-item-left">
<span v-if="displayLegs.length > 1" class="sel-index">{{ idx + 1 }}</span>
<div class="sel-item-main">
<span class="sel-item-match">{{ leg.matchTitle || t('bet.match') }}</span>
<span class="sel-item-pick">
<template v-if="leg.marketLabel">{{ leg.marketLabel }} · </template>
{{ translateSel(leg.selectionName || '') }}
</span>
<span v-if="leg.score?.ft" class="sel-item-score">{{ t('history.ft') }}: {{ leg.score.ft }}</span>
</div>
</div>
<div class="sel-item-right">
<span class="sel-item-odds">@{{ leg.odds ?? '-' }}</span>
<span
v-if="leg.resultStatus"
class="leg-result"
:class="legResultClass(leg.resultStatus)"
>
{{ legResultIcon(leg.resultStatus) }}
</span>
</div>
</div>
</div>
</template>
<div v-else class="modal-empty">{{ t('common.not_found') }}</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.65);
}
.modal-container {
width: 100%;
max-width: 560px;
max-height: 82vh;
display: flex;
flex-direction: column;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-title {
font-size: 15px;
font-weight: 800;
color: var(--text);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.modal-loading,
.modal-error,
.modal-empty {
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: 7px 18px;
border-radius: 6px;
border: 1px solid var(--border-active);
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.summary-strip {
padding: 16px 18px;
border-bottom: 1px solid var(--border);
background: rgba(0, 102, 204, 0.04);
}
.summary-main {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.bet-no {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
}
.summary-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.metric {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.metric-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
}
.metric-val {
font-size: 14px;
font-weight: 800;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.metric-val.accent {
color: var(--primary);
}
.metric-val.muted {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.mono {
font-family: var(--font-mono);
}
.st-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.st-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
.st-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
.st-pending { background: rgba(0, 102, 204, 0.12); color: var(--primary-light); }
.st-push { background: rgba(100, 100, 100, 0.12); color: #aaa; }
.st-cancelled { background: rgba(100, 100, 100, 0.1); color: #888; }
.st-default { background: rgba(100, 100, 100, 0.1); color: #888; }
.sel-section {
padding: 14px 18px 18px;
}
.sel-section-title {
font-size: 11px;
font-weight: 800;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.sel-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.sel-item:last-child {
border-bottom: none;
}
.sel-item-left {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.sel-index {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--bg-hover);
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
flex-shrink: 0;
}
.sel-item-main {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.sel-item-match {
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.sel-item-pick {
font-size: 12px;
color: var(--text-muted);
}
.sel-item-score {
font-size: 11px;
color: var(--text-muted);
}
.sel-item-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.sel-item-odds {
font-size: 13px;
font-weight: 800;
color: var(--primary);
font-family: var(--font-mono);
}
.leg-result {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 12px;
font-weight: 800;
}
.leg-won { background: rgba(52, 199, 89, 0.15); color: #4cd964; }
.leg-lost { background: rgba(255, 69, 58, 0.15); color: #ff453a; }
.leg-push { background: rgba(100, 100, 100, 0.15); color: #aaa; }
.leg-pending { background: rgba(0, 102, 204, 0.1); color: var(--primary-light); font-size: 10px; }
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.15s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
@media (max-width: 640px) {
.summary-metrics {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { watch } from 'vue';
import { useI18n } from 'vue-i18n';
import BetSlipPanel from './BetSlipPanel.vue';
import { useBetSlipStore } from '../../stores/betSlip';
const { t } = useI18n();
const slip = useBetSlipStore();
watch(
() => slip.totalCount,
(next, prev) => {
if (next > prev) slip.expandPanel();
},
);
</script>
<template>
<aside
class="desktop-betslip-rail"
:class="{ 'is-collapsed': !slip.panelExpanded }"
:aria-expanded="slip.panelExpanded"
>
<button
type="button"
class="rail-toggle"
:class="{ 'is-collapsed': !slip.panelExpanded }"
:aria-label="slip.panelExpanded ? t('common.collapse') : t('common.expand')"
@click="slip.togglePanelExpanded()"
>
<svg class="rail-toggle-icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M10 8l4 4-4 4"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
<button
v-if="!slip.panelExpanded"
type="button"
class="rail-collapsed-hint"
:aria-label="t('bet.bet_slip')"
@click="slip.expandPanel()"
>
<span v-if="slip.totalCount" class="rail-badge">{{ slip.totalCount > 99 ? '99+' : slip.totalCount }}</span>
<span class="rail-label">{{ t('bet.bet_slip') }}</span>
</button>
<div class="rail-content" :hidden="!slip.panelExpanded">
<BetSlipPanel />
</div>
</aside>
</template>
<style scoped>
.desktop-betslip-rail {
position: relative;
flex-shrink: 0;
width: var(--desktop-right-betslip-w);
min-width: 0;
border-left: 1px solid var(--border);
background: var(--bg-card);
display: flex;
flex-direction: column;
min-height: 0;
transition: width 0.28s cubic-bezier(0.4, 0, 0.2, 1);
overflow: visible;
}
.rail-toggle {
position: absolute;
left: -18px;
top: 50%;
z-index: 30;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card);
color: var(--primary-light);
cursor: pointer;
display: grid;
place-items: center;
transform: translateY(-50%);
box-shadow:
0 2px 10px rgba(0, 61, 107, 0.12),
0 0 0 3px rgba(255, 255, 255, 0.92);
transition:
transform 0.22s cubic-bezier(0.4, 0, 0.2, 1),
background 0.2s,
color 0.2s,
border-color 0.2s,
box-shadow 0.2s;
}
.rail-toggle-icon {
width: 16px;
height: 16px;
transition: transform 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.rail-toggle.is-collapsed .rail-toggle-icon {
transform: rotate(180deg);
}
.rail-toggle:hover {
border-color: var(--border-active);
background: var(--primary);
color: #fff;
box-shadow:
0 4px 14px rgba(0, 61, 107, 0.2),
0 0 0 3px rgba(0, 102, 204, 0.1);
}
.rail-toggle:active {
transform: translateY(-50%) scale(0.94);
}
.rail-toggle:focus-visible {
outline: 2px solid var(--border-active);
outline-offset: 2px;
}
.rail-content {
flex: 1;
min-height: 0;
width: var(--desktop-right-betslip-expanded-w, 288px);
overflow: hidden;
}
.desktop-betslip-rail.is-collapsed .rail-content {
visibility: hidden;
}
.rail-collapsed-hint {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 16px 0;
border: none;
background: transparent;
cursor: pointer;
transition: background 0.2s;
}
.rail-collapsed-hint:hover {
background: var(--bg-hover);
}
.rail-label {
writing-mode: vertical-rl;
text-orientation: mixed;
font-size: 12px;
font-weight: 800;
font-family: var(--font-heading);
color: var(--primary);
letter-spacing: 0.12em;
}
.rail-badge {
min-width: 20px;
height: 20px;
padding: 0 5px;
border-radius: 10px;
background: var(--odds-accent, #F8971F);
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 20px;
text-align: center;
}
</style>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
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 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));
function formatPeriod(start: string, end: string) {
const opts: Intl.DateTimeFormatOptions = { year: 'numeric', 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);
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal-container" role="dialog" aria-modal="true" :aria-label="t('cashback.title')">
<div class="modal-header">
<span class="modal-title">{{ t('cashback.title') }}</span>
<button type="button" class="modal-close" :aria-label="t('support.close')" @click="close"></button>
</div>
<div v-if="record" class="modal-body">
<div class="amount-hero pos">{{ formatMoney(record.amount, locale) }}</div>
<div class="type-label">{{ formatPeriod(record.periodStart, record.periodEnd) }}</div>
<div class="info-rows">
<div class="info-row">
<span class="row-label">{{ t('cashback.effective_stake') }}</span>
<span class="row-val">{{ formatMoneyCompact(record.effectiveStake, locale) }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('cashback.rate') }}</span>
<span class="row-val">{{ formatRate(record.rate) }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('nav.bet_history') }}</span>
<span class="row-val">{{ t('cashback.bet_count', { n: record.betCount }) }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('cashback.batch_no') }}</span>
<span class="row-val mono muted">{{ record.batchNo }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('wallet.time') }}</span>
<span class="row-val muted">{{ formatTime(record.confirmedAt ?? record.createdAt) }}</span>
</div>
</div>
</div>
<div v-else class="modal-empty">{{ t('common.not_found') }}</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.65);
}
.modal-container {
width: 100%;
max-width: 480px;
max-height: 82vh;
display: flex;
flex-direction: column;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-title {
font-size: 15px;
font-weight: 800;
color: var(--text);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.modal-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 20px;
color: var(--text-muted);
font-size: 13px;
}
.amount-hero {
padding: 20px 18px 8px;
font-size: 32px;
font-weight: 800;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
line-height: 1.1;
}
.amount-hero.pos {
color: var(--primary);
}
.type-label {
padding: 0 18px 16px;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.info-rows {
padding: 4px 0 8px;
}
.info-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 11px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child {
border-bottom: none;
}
.row-label {
width: 96px;
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: var(--font-mono);
font-size: 12px;
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.15s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -12,7 +12,7 @@ import { buildBetPlaceConfirmMessage } from '../../utils/betPlaceConfirmMessage'
import { useAppToast } from '../../composables/useAppToast';
const { t, locale } = useI18n();
const { visible, anchorX, anchorY, pendingItem, close } = useDesktopBetPopover();
const { visible, anchorX, anchorY, pendingItem, placement, close, cancelClose, scheduleClose } = useDesktopBetPopover();
const slip = useBetSlipStore();
const auth = useAuthStore();
const { refreshProfile } = usePlayerProfile();
@@ -25,6 +25,16 @@ const error = ref('');
const showPlaceConfirm = ref(false);
const placeConfirmMessage = ref('');
const MIN_STAKE = 5;
const confirmingItem = ref<typeof pendingItem.value>(null);
watch(showPlaceConfirm, (val) => {
if (!val) confirmingItem.value = null;
});
function onPopoverMouseLeave() {
if (showPlaceConfirm.value) return;
scheduleClose(200);
}
let outsideClickTimer = 0;
@@ -95,6 +105,7 @@ function validatePlaceNow(): boolean {
function onPlaceNowClick() {
if (!validatePlaceNow()) return;
const item = pendingItem.value!;
confirmingItem.value = item;
placeConfirmMessage.value = buildBetPlaceConfirmMessage(t, {
mode: 'single',
items: [item],
@@ -107,7 +118,7 @@ function onPlaceNowClick() {
showPlaceConfirm.value = true;
}
async function executePlaceNow(item = pendingItem.value) {
async function executePlaceNow(item = confirmingItem.value || pendingItem.value) {
if (!item) return;
loading.value = true;
error.value = '';
@@ -134,7 +145,7 @@ async function executePlaceNow(item = pendingItem.value) {
}
async function confirmPlaceNow() {
const item = pendingItem.value;
const item = confirmingItem.value;
if (!item) return;
showPlaceConfirm.value = false;
await executePlaceNow(item);
@@ -184,11 +195,13 @@ function addToParlayList() {
v-if="visible && pendingItem"
ref="popRef"
class="bet-popover"
:class="`placement-${placement}`"
tabindex="-1"
:style="{ left: `${anchorX}px`, top: `${anchorY}px` }"
@click.stop
@mouseenter="cancelClose"
@mouseleave="onPopoverMouseLeave"
>
<button type="button" class="pop-close" aria-label="Close" @click="close"></button>
<div class="pop-match">{{ pendingItem.matchName }}</div>
<div class="pop-market">{{ pendingItem.marketName }}</div>
@@ -239,98 +252,138 @@ function addToParlayList() {
.bet-popover {
position: fixed;
z-index: 1000;
width: 280px;
padding: 12px 28px 12px 12px;
border-radius: 8px;
border: 1px solid var(--border-gold-soft);
background: rgba(18, 18, 18, 0.98);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
width: 172px;
padding: 6px 7px;
border-radius: 6px;
border: 1px solid var(--border-active);
background: var(--bg-card);
box-shadow: 0 6px 18px rgba(0, 61, 107, 0.14);
}
.pop-close {
.bet-popover::after,
.bet-popover::before {
content: '';
position: absolute;
top: 6px;
right: 6px;
background: none;
border: none;
color: var(--text-muted);
font-size: 13px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
width: 0;
height: 0;
border-style: solid;
pointer-events: none;
}
.pop-close:hover {
color: #fff;
.bet-popover.placement-bottom::after {
top: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 0 6px 6px 6px;
border-color: transparent transparent var(--bg-card) transparent;
z-index: 2;
}
.bet-popover.placement-bottom::before {
top: -7px;
left: 50%;
transform: translateX(-50%);
border-width: 0 7px 7px 7px;
border-color: transparent transparent var(--border-active) transparent;
z-index: 1;
}
.bet-popover.placement-top::after {
bottom: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 6px 6px 0 6px;
border-color: var(--bg-card) transparent transparent transparent;
z-index: 2;
}
.bet-popover.placement-top::before {
bottom: -7px;
left: 50%;
transform: translateX(-50%);
border-width: 7px 7px 0 7px;
border-color: var(--border-active) transparent transparent transparent;
z-index: 1;
}
.pop-match {
font-size: 11px;
font-size: 9px;
color: var(--text-muted);
line-height: 1.35;
margin-bottom: 4px;
line-height: 1.3;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pop-market {
font-size: 10px;
font-size: 8px;
color: var(--text-muted);
margin-bottom: 6px;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pop-pick-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
padding: 6px 8px;
margin-bottom: 10px;
border-radius: 4px;
background: rgba(212, 175, 55, 0.06);
border: 1px solid var(--border-gold-soft);
gap: 6px;
padding: 4px 6px;
margin-bottom: 5px;
border-radius: 3px;
background: rgba(0, 102, 204, 0.06);
border: 1px solid var(--border-active);
}
.pick {
font-size: 12px;
font-size: 10px;
font-weight: 700;
color: #fff;
color: var(--text);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.odds {
font-size: 12px;
font-size: 10px;
font-weight: 800;
color: var(--primary-light);
color: var(--primary);
flex-shrink: 0;
}
.stake-label {
display: block;
font-size: 10px;
font-size: 8px;
color: var(--text-muted);
margin-bottom: 4px;
margin-bottom: 2px;
}
.stake-input {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
margin-bottom: 8px;
border-radius: 4px;
padding: 4px 6px;
margin-bottom: 5px;
border-radius: 3px;
border: 1px solid var(--border);
background: #0d0d0d;
color: #fff;
font-size: 12px;
background: var(--bg-card);
color: var(--text);
font-size: 10px;
}
.stake-input:focus {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
outline: none;
}
.est-row {
display: flex;
justify-content: space-between;
font-size: 10px;
font-size: 8px;
color: var(--text-muted);
margin-bottom: 10px;
margin-bottom: 6px;
}
.est-val {
@@ -339,20 +392,20 @@ function addToParlayList() {
}
.pop-error {
font-size: 10px;
font-size: 8px;
color: var(--danger);
margin: 0 0 8px;
margin: 0 0 5px;
}
.pop-actions {
display: flex;
flex-direction: column;
gap: 6px;
gap: 4px;
}
.pop-actions-row {
display: flex;
gap: 6px;
gap: 4px;
}
.pop-actions-row .btn-outline {
@@ -363,12 +416,12 @@ function addToParlayList() {
.btn-primary-gold {
width: 100%;
padding: 8px;
padding: 5px;
border: none;
border-radius: 4px;
background: linear-gradient(180deg, #f0d875 0%, #d4af37 100%);
color: #3d2800;
font-size: 12px;
border-radius: 3px;
background: linear-gradient(180deg, var(--primary-light) 0%, var(--primary) 100%);
color: #FFFFFF;
font-size: 10px;
font-weight: 800;
cursor: pointer;
}
@@ -384,17 +437,17 @@ function addToParlayList() {
.btn-outline {
width: 100%;
padding: 7px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.05);
padding: 4px;
border-radius: 3px;
border: 1px solid var(--border-active);
background: rgba(0, 102, 204, 0.05);
color: var(--primary-light);
font-size: 11px;
font-size: 9px;
font-weight: 700;
cursor: pointer;
}
.btn-outline:hover {
background: rgba(212, 175, 55, 0.12);
background: rgba(0, 102, 204, 0.12);
}
</style>

View File

@@ -1,14 +1,17 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import type { OutrightEvent } from '../outright/OutrightEventSection.vue';
import type { OutrightEvent, OutrightSelection } from '../outright/OutrightEventSection.vue';
import saishiImg from '../../assets/images/saishi.webp';
const props = defineProps<{
event: OutrightEvent;
}>();
const emit = defineEmits<{ open: [] }>();
const emit = defineEmits<{
open: [];
pick: [selection: OutrightSelection];
}>();
const { t } = useI18n();
@@ -22,38 +25,129 @@ const teamCount = computed(() => props.event.selectionCount ?? props.event.selec
const isSettled = computed(
() => props.event.bettingOpen === false || props.event.status === 'SETTLED',
);
const MIN_BTN_W = 52;
const GAP = 4;
const cardRef = ref<HTMLElement | null>(null);
const cardWidth = ref(400);
let ro: ResizeObserver | null = null;
onMounted(() => {
if (!cardRef.value) return;
ro = new ResizeObserver(([entry]) => {
cardWidth.value = entry.contentRect.width;
});
ro.observe(cardRef.value);
});
onUnmounted(() => ro?.disconnect());
const maxVisible = computed(() => {
const panelW = cardWidth.value - 24;
return Math.max(1, Math.min(5, Math.floor((panelW + GAP) / (MIN_BTN_W + GAP))));
});
const topSelections = computed(() => {
if (!props.event.selections) return [];
return [...props.event.selections]
.sort((a, b) => {
const oa = parseFloat(a.odds) || 9999;
const ob = parseFloat(b.odds) || 9999;
return oa - ob;
})
.slice(0, maxVisible.value);
});
</script>
<template>
<button type="button" class="outright-event-card hover-bg" @click="emit('open')">
<div class="card-main">
<div class="title-row">
<span class="title">{{ headTitle }}</span>
<span v-if="isSettled" class="settled-tag">{{ t('bet.outright_settled') }}</span>
<article ref="cardRef" class="outright-event-card" :class="{ settled: isSettled }">
<div class="card-clickable-area" @click="emit('open')">
<div class="card-main">
<div class="title-row">
<span class="title">{{ headTitle }}</span>
<span v-if="isSettled" class="settled-tag">{{ t('bet.outright_settled') }}</span>
</div>
<p v-if="event.leagueName && event.leagueName !== headTitle" class="league">{{ event.leagueName }}</p>
<p class="meta">{{ t('bet.outright_teams_count', { n: teamCount }) }}</p>
</div>
<p v-if="event.leagueName && event.leagueName !== headTitle" class="league">{{ event.leagueName }}</p>
<p class="meta">{{ t('bet.outright_teams_count', { n: teamCount }) }}</p>
<img :src="saishiImg" alt="" class="saishi" />
</div>
<img :src="saishiImg" alt="" class="saishi" />
</button>
<div v-if="!isSettled && topSelections.length" class="quick-bet-panel">
<div class="quick-bet-title">
{{ t('bet.quick_bet') }}
<span class="quick-bet-hint">{{ t('bet.popular_teams') }}</span>
</div>
<div class="odds-row">
<button
v-for="sel in topSelections"
:key="sel.id"
type="button"
class="odds-btn"
@click.stop="emit('pick', sel)"
>
<span class="selection-name" :title="sel.teamName">{{ sel.teamName }}</span>
<span class="selection-odds">{{ sel.odds }}</span>
</button>
</div>
<button type="button" class="view-all-btn" @click.stop="emit('open')">
{{ t('bet.outright_view_all') }}
</button>
</div>
</article>
</template>
<style scoped>
.outright-event-card {
position: relative;
width: 100%;
min-height: 140px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
@media (hover: hover) {
.outright-event-card:hover {
border-color: var(--border-active);
box-shadow: 0 4px 12px rgba(0, 61, 107, 0.1);
}
.outright-event-card:hover .card-clickable-area {
transform: translateY(-10px);
}
.outright-event-card:hover .league,
.outright-event-card:hover .meta {
opacity: 0;
}
.outright-event-card:hover .saishi {
transform: scale(0.9);
opacity: 0;
}
.outright-event-card:hover .quick-bet-panel {
opacity: 1;
pointer-events: auto;
transform: translateY(0);
}
}
.card-clickable-area {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(20, 20, 20, 0.95);
text-align: left;
transition: border-color 0.2s, background 0.2s;
}
.outright-event-card:hover {
border-color: var(--border-gold-soft);
cursor: pointer;
flex: 1;
transition: transform 0.25s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.card-main {
@@ -76,29 +170,35 @@ const isSettled = computed(
font-weight: 800;
color: var(--primary-light);
line-height: 1.35;
transition: color 0.2s;
}
.outright-event-card:hover .title {
color: var(--primary);
}
.settled-tag {
font-size: 10px;
font-weight: 800;
color: #c9a227;
color: var(--odds-accent);
border: 1px solid rgba(201, 162, 39, 0.45);
border-radius: 999px;
padding: 1px 7px;
}
.league {
margin: 0;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.league,
.meta {
margin: 0;
font-size: 11px;
font-weight: 600;
color: #666;
color: var(--text-muted);
transition: opacity 0.15s ease;
}
.league {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.saishi {
@@ -108,5 +208,106 @@ const isSettled = computed(
max-width: 40px;
object-fit: contain;
opacity: 0.85;
transition: transform 0.25s ease, opacity 0.2s ease;
}
.quick-bet-panel {
position: absolute;
left: 12px;
right: 12px;
bottom: 10px;
display: flex;
flex-direction: column;
gap: 5px;
overflow: hidden;
opacity: 0;
pointer-events: none;
transform: translateY(12px);
transition: opacity 0.2s ease, transform 0.22s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 5;
}
.quick-bet-title {
font-size: 9px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.quick-bet-hint {
font-size: 9px;
font-weight: 600;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
margin-left: 1px;
}
.odds-row {
display: flex;
gap: 4px;
width: 100%;
overflow: hidden;
}
.odds-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
gap: 1px;
padding: 2px 4px;
border-radius: 4px;
background: var(--bg-body);
border: 1px solid var(--border);
cursor: pointer;
min-height: 32px;
transition: all 0.2s ease;
}
.odds-btn:hover {
background: rgba(0, 102, 204, 0.08);
border-color: var(--border-active);
}
.selection-name {
font-size: 9px;
font-weight: 700;
color: var(--text-muted);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.odds-btn:hover .selection-name {
color: var(--text);
}
.selection-odds {
font-size: 11px;
font-weight: 900;
color: var(--primary);
line-height: 1.1;
}
.view-all-btn {
align-self: flex-end;
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: none;
border: none;
cursor: pointer;
padding: 2px 0;
opacity: 0.85;
transition: opacity 0.15s ease;
}
.view-all-btn:hover {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,566 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
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();
type RechargeDetail = PlayerDepositOrder;
const detail = computed(() => props.order);
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));
function statusClass(status: string) {
const map: Record<string, string> = {
APPROVED: 'st-won',
REJECTED: 'st-lost',
PENDING: 'st-pending',
};
return map[status?.toUpperCase()] ?? 'st-default';
}
function statusLabel(status: string) {
const s = status?.toUpperCase();
if (s === 'APPROVED') return t('recharge.status_approved');
if (s === 'REJECTED') return t('recharge.status_rejected');
return t('recharge.status_pending');
}
function methodLabel(order: RechargeDetail) {
if (order.methodType === 'USDT') return 'USDT';
return order.paymentMethodName || order.methodType || '-';
}
function reapply(order: RechargeDetail) {
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: RechargeDetail): { label: string; text: string } | null {
const rejectReason = normalizeText(order.rejectReason);
const remark = normalizeText(order.remark);
if (order.status === 'REJECTED') {
const text = rejectReason || remark;
if (!text) return null;
return { label: t('recharge.reject_reason'), text };
}
if (remark) return { label: t('recharge.remark'), text: remark };
return null;
}
function 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: RechargeDetail) {
if (log.action === 'REJECTED' && !shouldShowAuditRejectInTimeline(log, order.rejectReason)) {
return null;
}
return formatDepositAuditRemark(log, t);
}
function auditLogsForDisplay(order: RechargeDetail) {
return [...(order.auditLogs ?? [])]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((log) => ({
log,
actor: auditActorSecondary(log, t),
remark: auditRemarkForTimeline(log, order),
}));
}
function auditNoteDisplayText(remark: { kind: 'note'; text: string }) {
if (remark.text === t('wallet.remark_deposit_revoke_generic')) return remark.text;
return `${t('recharge.audit_remark_label')}: ${remark.text}`;
}
</script>
<template>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal-container" role="dialog" aria-modal="true" :aria-label="t('recharge.order_detail')">
<div class="modal-header">
<span class="modal-title">{{ t('recharge.order_detail') }}</span>
<button type="button" class="modal-close" :aria-label="t('support.close')" @click="close"></button>
</div>
<div class="modal-body">
<template v-if="detail">
<div class="summary-strip">
<div class="summary-main">
<span class="order-no mono">{{ detail.orderNo }}</span>
<span class="st-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
</div>
<div class="amount-hero">{{ formatMoney(detail.amount, locale) }}</div>
<div class="method-label">{{ methodLabel(detail) }}</div>
</div>
<div class="info-rows">
<div
v-if="detail.approvedAmount && detail.approvedAmount !== detail.amount"
class="info-row"
>
<span class="row-label">{{ t('recharge.credited') }}</span>
<span class="row-val accent">{{ formatMoney(detail.approvedAmount, locale) }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('recharge.apply_time') }}</span>
<span class="row-val muted">{{ new Date(detail.createdAt).toLocaleString() }}</span>
</div>
<div v-if="detail.reviewedAt" class="info-row">
<span class="row-label">{{ t('recharge.review_time') }}</span>
<span class="row-val muted">{{ new Date(detail.reviewedAt).toLocaleString() }}</span>
</div>
<div v-if="orderNote(detail)" class="info-row info-row--note">
<span class="row-label">{{ orderNote(detail)!.label }}</span>
<span class="row-val" :class="{ reject: detail.status === 'REJECTED' }">
{{ orderNote(detail)!.text }}
</span>
</div>
</div>
<div v-if="detail.auditLogs?.length" class="audit-section">
<div class="section-title">{{ t('recharge.audit_title') }}</div>
<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 v-if="detail.status === 'REJECTED'" class="modal-actions">
<button type="button" class="reapply-btn" @click="reapply(detail)">
{{ t('recharge.reapply') }}
</button>
</div>
</template>
<div v-else class="modal-empty">{{ t('common.not_found') }}</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.65);
}
.modal-container {
width: 100%;
max-width: 520px;
max-height: 82vh;
display: flex;
flex-direction: column;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-title {
font-size: 15px;
font-weight: 800;
color: var(--text);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.modal-loading,
.modal-error,
.modal-empty {
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: 7px 18px;
border-radius: 6px;
border: 1px solid var(--border-active);
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.summary-strip {
padding: 16px 18px;
border-bottom: 1px solid var(--border);
background: rgba(0, 102, 204, 0.04);
}
.summary-main {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.order-no {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
}
.amount-hero {
font-size: 30px;
font-weight: 800;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
color: var(--text);
line-height: 1.1;
margin-bottom: 4px;
}
.method-label {
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
}
.mono {
font-family: var(--font-mono);
}
.st-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
}
.st-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
.st-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
.st-pending { background: rgba(0, 102, 204, 0.12); color: var(--primary-light); }
.st-default { background: rgba(100, 100, 100, 0.1); color: #888; }
.info-rows {
padding: 4px 0;
}
.info-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 11px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row--note {
flex-direction: column;
gap: 6px;
}
.row-label {
width: 96px;
flex-shrink: 0;
font-size: 12px;
color: var(--text-muted);
font-weight: 600;
}
.info-row--note .row-label {
width: auto;
}
.row-val {
flex: 1;
font-size: 13px;
font-weight: 700;
color: var(--text);
word-break: break-word;
}
.row-val.accent {
color: var(--primary);
}
.row-val.muted {
color: var(--text-muted);
font-weight: 600;
}
.row-val.reject {
color: var(--danger);
}
.audit-section {
padding: 14px 18px 18px;
border-top: 1px solid var(--border);
}
.section-title {
font-size: 11px;
font-weight: 800;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 12px;
}
.audit-step {
display: flex;
gap: 10px;
}
.audit-step-rail {
flex: 0 0 12px;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 5px;
}
.audit-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #555;
flex-shrink: 0;
}
.audit-line {
flex: 1;
width: 1px;
min-height: 12px;
margin: 4px 0;
background: rgba(0, 0, 0, 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: #4cd964;
}
.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: #c9a227; }
.modal-actions {
padding: 0 18px 18px;
}
.reapply-btn {
width: 100%;
padding: 10px 16px;
border-radius: 8px;
border: 1px solid var(--border-active);
background: rgba(0, 102, 204, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.reapply-btn:hover {
background: rgba(0, 102, 204, 0.14);
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.15s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -7,15 +7,19 @@ import DesktopOddsBetPopover from './DesktopOddsBetPopover.vue';
import SportsCategoryBar from './SportsCategoryBar.vue';
import LeagueSidebar from './LeagueSidebar.vue';
import MatchSidebar from './MatchSidebar.vue';
import BetSlipPanel from './BetSlipPanel.vue';
import OutrightSidebar from './OutrightSidebar.vue';
import DesktopBetSlipRail from './DesktopBetSlipRail.vue';
import FloatingMailbox from '../FloatingMailbox.vue';
import AnnouncementMarquee from '../AnnouncementMarquee.vue';
import { usePlayerHome } from '../../composables/usePlayerHome';
import { useAuthStore } from '../../stores/auth';
import { useBetSlipStore } from '../../stores/betSlip';
const route = useRoute();
const { locale } = useI18n();
const auth = useAuthStore();
const { load: loadPlayerHome } = usePlayerHome();
const slip = useBetSlipStore();
const { load: loadPlayerHome, announcements } = usePlayerHome();
const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
const p = route.path;
@@ -26,7 +30,7 @@ const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
p === '/bets' ||
p.startsWith('/bets/') ||
p.startsWith('/wallet') ||
p === '/profile/cashbacks'
p.startsWith('/profile')
) {
return 'account';
}
@@ -40,10 +44,23 @@ const layoutMode = computed<'betting' | 'account' | 'hub' | 'marketing'>(() => {
});
const isMatchDetail = computed(() => route.path.startsWith('/match/'));
const isDetailPage = computed(
() => route.path.startsWith('/match/') || route.path.startsWith('/outright/'),
);
const isBettingDetail = computed(
() => route.path.startsWith('/match/') || route.path.startsWith('/outright/'),
);
const showBetSlipRail = computed(() => {
const p = route.path;
if (p === '/bets' || p.startsWith('/bets/')) return false;
if (p.startsWith('/wallet') || p.startsWith('/profile')) return false;
if (route.params.id && (p.startsWith('/announcements/') || p.startsWith('/messages/'))) {
return false;
}
return true;
});
onMounted(() => {
void loadPlayerHome(true);
});
@@ -61,11 +78,22 @@ watch(
</script>
<template>
<div class="desktop-shell">
<div
class="desktop-shell"
:class="{
'betslip-collapsed': showBetSlipRail && !slip.panelExpanded,
'betslip-hidden': !showBetSlipRail,
}"
>
<div v-if="announcements && announcements.length" class="desktop-marquee-wrap">
<AnnouncementMarquee :items="announcements" embedded />
</div>
<header class="desktop-header-wrap">
<DesktopTopNav />
</header>
<div class="desktop-body">
<div class="desktop-main-container">
<!-- Betting Layout -->
<div v-if="layoutMode === 'betting'" class="desktop-layout-betting-wrap">
@@ -74,8 +102,9 @@ watch(
</div>
<div class="desktop-layout-betting">
<aside class="desktop-betting-left">
<LeagueSidebar v-if="!isMatchDetail" />
<MatchSidebar v-else />
<LeagueSidebar v-if="!isDetailPage" />
<MatchSidebar v-else-if="isMatchDetail" />
<OutrightSidebar v-else />
</aside>
<main class="desktop-betting-center" :class="{ 'is-betting-detail': isBettingDetail }">
<RouterView v-slot="{ Component, route: viewRoute }">
@@ -85,9 +114,6 @@ watch(
<component v-else :is="Component" :key="viewRoute.fullPath" />
</RouterView>
</main>
<aside class="desktop-betting-right">
<BetSlipPanel />
</aside>
</div>
</div>
@@ -114,7 +140,7 @@ watch(
</div>
<!-- Marketing/Single Column Layout -->
<div v-else class="desktop-layout-marketing">
<div v-else class="desktop-layout-marketing no-scrollbar">
<main class="desktop-marketing-content">
<RouterView v-slot="{ Component, route: viewRoute }">
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
@@ -125,6 +151,8 @@ watch(
</main>
</div>
</div>
<DesktopBetSlipRail v-if="showBetSlipRail" />
</div>
<FloatingMailbox />
<DesktopOddsBetPopover />
</div>

View File

@@ -16,8 +16,7 @@ const activeTab = computed(() => {
if (p === '/') return 'home';
if (p === '/bet' || p.startsWith('/match/') || p.startsWith('/outright/')) return 'sports';
if (p === '/bets' || p.startsWith('/bets/')) return 'bet_history';
if (p.startsWith('/wallet') || p === '/profile/cashbacks') return 'wallet';
if (p.startsWith('/profile')) return 'profile';
if (p.startsWith('/wallet') || p.startsWith('/profile')) return 'my_wallet';
if (p.startsWith('/messages')) return 'messages';
return '';
});
@@ -57,17 +56,9 @@ const activeTab = computed(() => {
<RouterLink
to="/wallet"
class="nav-link"
:class="{ active: activeTab === 'wallet' }"
:class="{ active: activeTab === 'my_wallet' }"
>
{{ t('nav.wallet') }}
</RouterLink>
<RouterLink
to="/profile"
class="nav-link"
:class="{ active: activeTab === 'profile' }"
>
{{ t('nav.profile') }}
{{ t('nav.my_wallet') }}
</RouterLink>
</nav>
@@ -114,18 +105,18 @@ const activeTab = computed(() => {
height: 38px;
width: auto;
display: block;
filter: drop-shadow(0 0 4px rgba(212, 175, 55, 0.2));
filter: drop-shadow(0 0 4px rgba(0, 102, 204, 0.2));
}
.site-name {
font-size: 20px;
font-weight: 800;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family: var(--font-heading);
letter-spacing: 0.5px;
background: var(--gradient-gold);
background: var(--primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
text-shadow: 0 0 8px rgba(212, 175, 55, 0.15);
text-shadow: 0 0 8px rgba(0, 102, 204, 0.15);
transition: opacity 0.2s;
}
@@ -144,6 +135,7 @@ const activeTab = computed(() => {
position: relative;
font-size: 14px;
font-weight: 700;
font-family: var(--font-heading);
color: var(--text-muted);
padding: 18px 4px;
transition: color 0.2s;
@@ -169,7 +161,7 @@ const activeTab = computed(() => {
height: 3px;
background: var(--primary);
border-radius: 3px 3px 0 0;
box-shadow: 0 -1px 8px rgba(212, 175, 55, 0.5);
box-shadow: 0 -1px 8px rgba(0, 102, 204, 0.5);
}
.badge {
@@ -203,8 +195,8 @@ const activeTab = computed(() => {
.login-btn {
padding: 6px 18px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.05);
border: 1px solid var(--border-active);
background: rgba(0, 102, 204, 0.05);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
@@ -212,15 +204,15 @@ const activeTab = computed(() => {
}
.login-btn:hover {
background: rgba(212, 175, 55, 0.12);
background: rgba(0, 102, 204, 0.12);
border-color: var(--primary);
}
.register-btn {
padding: 7px 18px;
border-radius: 4px;
background: linear-gradient(180deg, #F0D875 0%, #D4AF37 100%);
color: #3D2800;
background: linear-gradient(180deg, var(--primary-light) 0%, var(--primary) 100%);
color: #FFFFFF;
font-size: 13px;
font-weight: 800;
transition: opacity 0.2s;

View File

@@ -0,0 +1,378 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../GoldSpinner.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;
[key: string]: unknown;
};
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 onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.visible) close();
}
onMounted(() => document.addEventListener('keydown', onKeydown));
onUnmounted(() => document.removeEventListener('keydown', onKeydown));
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>
<Teleport to="body">
<Transition name="modal-fade">
<div v-if="visible" class="modal-overlay" @click.self="close">
<div class="modal-container" role="dialog" aria-modal="true" :aria-label="t('wallet.transaction_detail')">
<div class="modal-header">
<span class="modal-title">{{ t('wallet.transaction_detail') }}</span>
<button type="button" class="modal-close" :aria-label="t('support.close')" @click="close"></button>
</div>
<div class="modal-body">
<div v-if="loading" class="modal-loading">
<GoldSpinner :size="32" />
</div>
<div v-else-if="error" class="modal-error">
<p>{{ t('common.load_failed') }}</p>
<button v-if="transactionId" type="button" class="retry-btn" @click="loadDetail(transactionId)">
{{ t('common.retry') }}
</button>
</div>
<template v-else-if="detail">
<div class="amount-hero" :class="parseFloat(detail.amount) >= 0 ? 'pos' : 'neg'">
{{ formatMoney(detail.amount, locale) }}
</div>
<div class="type-label">{{ txLabel(detail) }}</div>
<div class="info-rows">
<div class="info-row">
<span class="row-label">{{ t('wallet.note') }}</span>
<span class="row-val">{{ txSummaryLabel(detail, t) || '-' }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('wallet.balance_before') }}</span>
<span class="row-val mono">{{ formatMoney(detail.balanceBefore, locale) }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('wallet.balance_after') }}</span>
<span class="row-val mono">{{ formatMoney(detail.balanceAfter, locale) }}</span>
</div>
<div v-if="showFrozen(detail)" class="info-row">
<span class="row-label">{{ t('wallet.frozen_before') }}</span>
<span class="row-val mono">{{ formatMoney(detail.frozenBefore, locale) }}</span>
</div>
<div v-if="showFrozen(detail)" class="info-row">
<span class="row-label">{{ t('wallet.frozen_after') }}</span>
<span class="row-val mono">{{ formatMoney(detail.frozenAfter, locale) }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('wallet.time') }}</span>
<span class="row-val muted">{{ new Date(detail.createdAt).toLocaleString() }}</span>
</div>
<div class="info-row">
<span class="row-label">{{ t('wallet.transaction_id') }}</span>
<span class="row-val mono muted">{{ detail.transactionId }}</span>
</div>
</div>
<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="modal-empty">{{ t('common.not_found') }}</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.65);
}
.modal-container {
width: 100%;
max-width: 480px;
max-height: 82vh;
display: flex;
flex-direction: column;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.modal-title {
font-size: 15px;
font-weight: 800;
color: var(--text);
}
.modal-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
}
.modal-close:hover {
color: var(--text);
}
.modal-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 0 8px;
}
.modal-loading,
.modal-error,
.modal-empty {
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: 7px 18px;
border-radius: 6px;
border: 1px solid var(--border-active);
background: transparent;
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.amount-hero {
padding: 20px 18px 8px;
font-size: 32px;
font-weight: 800;
font-variant-numeric: tabular-nums;
font-family: var(--font-mono);
line-height: 1.1;
}
.amount-hero.pos {
color: var(--primary);
}
.amount-hero.neg {
color: var(--danger);
}
.type-label {
padding: 0 18px 16px;
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.info-rows {
padding: 4px 0;
}
.info-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 11px 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-row:last-child {
border-bottom: none;
}
.row-label {
width: 96px;
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: var(--font-mono);
font-size: 12px;
}
.link-actions {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 18px 16px;
border-top: 1px solid var(--border);
}
.link-btn {
width: 100%;
padding: 9px 14px;
border-radius: 6px;
border: 1px solid var(--border-active);
background: rgba(0, 102, 204, 0.06);
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
cursor: pointer;
text-align: left;
}
.link-btn:hover {
background: rgba(0, 102, 204, 0.12);
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.15s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { RouterLink } from 'vue-router';
import DesktopWalletSubNav from './DesktopWalletSubNav.vue';
defineProps<{
showRecharge?: boolean;
}>();
const { t } = useI18n();
</script>
<template>
<header class="desktop-records-header wallet-page-header">
<div class="wallet-page-header-main">
<DesktopWalletSubNav />
</div>
<div class="desktop-records-actions">
<slot name="actions">
<RouterLink
v-if="showRecharge !== false"
to="/wallet/recharge"
class="desktop-records-action-btn"
>
{{ t('wallet.recharge_btn') }}
</RouterLink>
</slot>
</div>
</header>
</template>
<style scoped>
.wallet-page-header {
align-items: center;
}
.wallet-page-header-main {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
</style>

View File

@@ -9,12 +9,15 @@ const route = useRoute();
const tabs = [
{
path: '/wallet',
labelKey: 'nav.wallet',
labelKey: 'wallet.my_balance',
match: (p: string) => p === '/wallet',
},
{
path: '/wallet/detail',
labelKey: 'wallet.view_all',
match: (p: string) =>
p === '/wallet' ||
p === '/wallet/detail' ||
p === '/wallet/recharge' ||
p.startsWith('/wallet/transactions'),
p.startsWith('/wallet/transactions/'),
},
{
path: '/wallet/recharge/history',
@@ -32,7 +35,7 @@ const activePath = computed(() => route.path);
</script>
<template>
<nav class="desktop-wallet-subnav" :aria-label="t('nav.wallet')">
<nav class="desktop-wallet-subnav" :aria-label="t('nav.my_wallet')">
<RouterLink
v-for="tab in tabs"
:key="tab.path"

View File

@@ -1,21 +1,37 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { computed, onMounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMatchFilters } from '../../composables/useMatchFilters';
import { useDesktopParlayMatches } from '../../composables/useDesktopParlayMatches';
import { useOutrightEvents } from '../../composables/useOutrightEvents';
import {
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
isInLocalTodayMatchWindow as isInTodayMatchWindow,
} from '@thebet365/shared';
const { t } = useI18n();
const route = useRoute();
const { parlayMatches, loadParlayMatches } = useDesktopParlayMatches();
const { searchQuery, filterState, toggleLeague, clearFilters } = useMatchFilters();
const { searchQuery, filterState, toggleLeague, clearFilters, isLeagueSelected } = useMatchFilters();
const { events: outrightEvents, load: loadOutrightEvents } = useOutrightEvents();
onMounted(() => {
void loadParlayMatches();
const isOutrightMode = computed(() => {
return route.query.tab === 'outright';
});
watch(
isOutrightMode,
(outright) => {
if (outright) {
void loadOutrightEvents({ silent: outrightEvents.value.length > 0 });
} else {
void loadParlayMatches();
}
},
{ immediate: true },
);
function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
@@ -26,43 +42,69 @@ function normalizeLeagueName(name: string): string {
const availableLeagues = computed(() => {
const map = new Map<string, { id: string; name: string; count: number }>();
const now = new Date();
const keyword = searchQuery.value.trim().toLowerCase();
for (const m of parlayMatches.value) {
if (keyword) {
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
if (!haystack.includes(keyword)) continue;
if (isOutrightMode.value) {
for (const e of outrightEvents.value) {
if (keyword) {
const haystack = `${e.title} ${e.leagueName || ''}`.toLowerCase();
if (!haystack.includes(keyword)) continue;
}
const id = e.leagueId ?? e.leagueName ?? 'unknown';
const existing = map.get(id);
if (existing) {
existing.count += 1;
} else {
map.set(id, {
id,
name: normalizeLeagueName(e.leagueName || e.title),
count: 1,
});
}
}
} else {
const now = new Date();
for (const m of parlayMatches.value) {
if (keyword) {
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
if (!haystack.includes(keyword)) continue;
}
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
}
if (!timeMatch) continue;
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
}
if (!timeMatch) continue;
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) continue;
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') continue;
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) continue;
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') continue;
const id = m.leagueId ?? m.leagueName;
const existing = map.get(id);
if (existing) {
existing.count += 1;
} else {
map.set(id, {
id,
name: normalizeLeagueName(m.leagueName),
count: 1,
});
const id = m.leagueId ?? m.leagueName;
const existing = map.get(id);
if (existing) {
existing.count += 1;
} else {
map.set(id, {
id,
name: normalizeLeagueName(m.leagueName),
count: 1,
});
}
}
}
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
});
const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(leagueId);
const isSelected = (leagueId: string) =>
isLeagueSelected(leagueId, filterState.value.leagueIds);
function onToggleLeague(leagueId: string) {
toggleLeague(leagueId, availableLeagues.value.map((lg) => lg.id));
}
</script>
<template>
@@ -76,7 +118,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
/>
</div>
<div class="sidebar-section">
<div v-if="!isOutrightMode" class="sidebar-section">
<div class="section-hdr">{{ t('bet.filter_time') }}</div>
<div class="time-filters">
<button
@@ -110,7 +152,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
<div class="section-hdr leagues-hdr">
<span>{{ t('bet.filter_league') }}</span>
<button
v-if="filterState.leagueIds.length > 0"
v-if="filterState.leagueIds.length > 0 || filterState.time !== 'all' || searchQuery.trim()"
type="button"
class="clear-btn"
@click="clearFilters"
@@ -124,14 +166,14 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
:key="lg.id"
class="league-item"
:class="{ active: isSelected(lg.id) }"
@click="toggleLeague(lg.id)"
@click="onToggleLeague(lg.id)"
>
<div class="checkbox" :class="{ checked: isSelected(lg.id) }"></div>
<span class="league-name" :title="lg.name">{{ lg.name }}</span>
<span class="league-count">{{ lg.count }}</span>
</div>
<div v-if="availableLeagues.length === 0" class="empty-leagues">
{{ t('bet.no_matches') }}
{{ isOutrightMode ? t('bet.no_outright') : t('bet.no_matches') }}
</div>
</div>
</div>
@@ -153,7 +195,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
.sidebar-search-input {
width: 100%;
background: #0d0d0d !important;
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
@@ -162,8 +204,8 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
}
.sidebar-search-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.1) !important;
border-color: var(--border-active) !important;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1) !important;
}
.sidebar-section {
@@ -187,7 +229,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
.time-btn {
flex: 1;
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 5px 2px;
@@ -199,12 +241,12 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
}
.time-btn:hover {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
color: var(--text);
}
.time-btn.active {
background: rgba(212, 175, 55, 0.08);
background: rgba(0, 102, 204, 0.08);
border-color: var(--primary);
color: var(--primary-light);
}
@@ -256,7 +298,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
}
.league-item.active {
background: rgba(212, 175, 55, 0.04);
background: rgba(0, 102, 204, 0.04);
}
.checkbox {
@@ -266,7 +308,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
border-radius: 2px;
position: relative;
flex-shrink: 0;
background: #0d0d0d;
background: var(--bg-card);
}
.checkbox.checked {
@@ -281,16 +323,17 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
left: 4px;
width: 3px;
height: 6px;
border: solid #111;
border: solid var(--bg-card);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.league-name {
flex: 1;
font-size: 11px;
font-weight: 600;
color: #ddd;
flex: 1;
font-size: 11px;
font-weight: 600;
font-family: var(--font-heading);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -305,7 +348,7 @@ const isSelected = (leagueId: string) => filterState.value.leagueIds.includes(le
font-size: 9px;
color: var(--text-muted);
font-weight: 700;
background: #181818;
background: var(--bg-body);
padding: 1px 5px;
border-radius: 8px;
}

View File

@@ -160,7 +160,7 @@ watch(currentMatchId, scrollToActive);
.sidebar-search-input {
width: 100%;
background: #0d0d0d !important;
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
@@ -169,8 +169,8 @@ watch(currentMatchId, scrollToActive);
}
.sidebar-search-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.1) !important;
border-color: var(--border-active) !important;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1) !important;
}
.sidebar-section {
@@ -200,7 +200,7 @@ watch(currentMatchId, scrollToActive);
.time-btn {
flex: 1;
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
padding: 5px 2px;
@@ -212,12 +212,12 @@ watch(currentMatchId, scrollToActive);
}
.time-btn:hover {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
color: var(--text);
}
.time-btn.active {
background: rgba(212, 175, 55, 0.08);
background: rgba(0, 102, 204, 0.08);
border-color: var(--primary);
color: var(--primary-light);
}
@@ -274,7 +274,8 @@ watch(currentMatchId, scrollToActive);
.matches-list :deep(.team-name) {
font-size: 12px;
font-weight: 700;
color: #ffffff;
font-family: var(--font-heading);
color: var(--text);
text-align: center;
padding: 0 4px;
max-width: 80px;
@@ -323,20 +324,20 @@ watch(currentMatchId, scrollToActive);
.matches-list :deep(.match-card) {
padding: 10px 8px;
min-height: unset;
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 6px;
gap: 6px;
}
.matches-list :deep(.match-card:hover) {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
background: var(--bg-hover) !important;
}
.matches-list :deep(.match-card.active) {
border-color: var(--primary) !important;
background: rgba(212, 175, 55, 0.08) !important;
box-shadow: 0 0 8px rgba(212, 175, 55, 0.25);
background: rgba(0, 102, 204, 0.08) !important;
box-shadow: 0 0 8px rgba(0, 102, 204, 0.25);
}
</style>

View File

@@ -0,0 +1,266 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useMatchFilters } from '../../composables/useMatchFilters';
import { useOutrightEvents } from '../../composables/useOutrightEvents';
import GoldSpinner from '../GoldSpinner.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const { searchQuery } = useMatchFilters();
const { events: outrightEvents, loading, load: loadOutrightEvents } = useOutrightEvents();
const listRef = ref<HTMLElement | null>(null);
onMounted(() => {
void loadOutrightEvents({ silent: outrightEvents.value.length > 0 }).then(() => {
scrollToActive();
});
});
const currentOutrightId = computed(() => {
const id = route.params.id;
return Array.isArray(id) ? id[0] : id;
});
function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const filteredEvents = computed(() => {
const keyword = searchQuery.value.trim().toLowerCase();
return outrightEvents.value.filter((e) => {
// If it's the active event, always show it
const isCurrent = String(e.id) === String(currentOutrightId.value);
if (isCurrent) return true;
if (keyword) {
const haystack = `${e.title} ${e.leagueName || ''}`.toLowerCase();
if (!haystack.includes(keyword)) return false;
}
return true;
});
});
function goOutright(id: string) {
router.replace(`/outright/${id}`);
}
function scrollToActive() {
nextTick(() => {
if (!listRef.value) return;
const activeEl = listRef.value.querySelector('.active') as HTMLElement;
if (activeEl) {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
watch(currentOutrightId, scrollToActive);
</script>
<template>
<div class="outright-sidebar">
<div class="search-box">
<input
v-model="searchQuery"
type="text"
:placeholder="t('nav.search')"
class="sidebar-search-input"
/>
</div>
<div class="sidebar-section outrights-section">
<div class="section-hdr">
<span>{{ t('bet.tab_outright') || '优胜冠军' }}</span>
</div>
<div class="outrights-list" ref="listRef">
<div v-if="loading && !filteredEvents.length" class="sidebar-loading">
<GoldSpinner :size="24" />
</div>
<template v-else>
<div
v-for="e in filteredEvents"
:key="e.id"
class="outright-card"
:class="{ active: String(e.id) === String(currentOutrightId) }"
@click="goOutright(e.id)"
>
<div class="card-header">
<span class="league-tag" :title="e.leagueName">{{ normalizeLeagueName(e.leagueName || e.title) }}</span>
</div>
<div class="event-title" :title="e.title">{{ e.title }}</div>
<div class="card-footer">
<span class="selections-badge">
{{ e.selections?.length || 0 }} {{ t('bet.selections') || '项' }}
</span>
</div>
</div>
<div v-if="filteredEvents.length === 0 && !loading" class="empty-outrights">
{{ t('bet.no_outright') }}
</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
.outright-sidebar {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px 0;
}
.search-box {
padding: 0 12px 10px;
border-bottom: 1px solid var(--border);
}
.sidebar-search-input {
width: 100%;
background: var(--bg-card) !important;
border: 1px solid var(--border) !important;
color: var(--text);
padding: 6px 10px;
font-size: 11px;
border-radius: 4px;
}
.sidebar-search-input:focus {
border-color: var(--border-active) !important;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.1) !important;
}
.sidebar-section {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
.section-hdr {
font-size: 10px;
font-weight: 800;
font-family: var(--font-heading);
text-transform: uppercase;
color: var(--text-muted);
letter-spacing: 0.05em;
margin-bottom: 8px;
}
.outrights-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
border-bottom: none;
padding-bottom: 0;
}
.outrights-list {
flex: 1;
overflow-y: auto;
margin-right: -4px;
padding-right: 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.sidebar-loading {
display: flex;
justify-content: center;
align-items: center;
padding: 30px 0;
}
.empty-outrights {
padding: 16px 0;
text-align: center;
font-size: 11px;
color: var(--text-muted);
}
.outright-card {
padding: 12px 10px;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 6px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
transition: all 0.2s ease;
}
.outright-card:hover {
border-color: var(--border-active);
background: var(--bg-hover) !important;
}
.outright-card.active {
border-color: var(--primary) !important;
background: rgba(0, 102, 204, 0.08) !important;
box-shadow: 0 0 8px rgba(0, 102, 204, 0.25);
}
.card-header {
display: flex;
align-items: center;
}
.league-tag {
font-size: 9px;
color: var(--primary-light);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.event-title {
font-size: 12px;
font-weight: 700;
color: var(--text);
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.outright-card.active .event-title {
color: var(--primary-light);
}
.card-footer {
display: flex;
justify-content: flex-start;
}
.selections-badge {
font-size: 9px;
color: var(--text-muted);
font-weight: 700;
background: var(--bg-card);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.03);
}
.outright-card.active .selections-badge {
background: rgba(0, 102, 204, 0.15);
color: var(--primary-light);
border-color: rgba(0, 102, 204, 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: #141414;
background: var(--bg-body);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
@@ -129,15 +129,15 @@ function onPageSizeChange(e: Event) {
}
.page-btn:hover:not(:disabled) {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
color: var(--text);
background: rgba(212, 175, 55, 0.05);
background: rgba(0, 102, 204, 0.05);
}
.page-btn.active {
background: var(--primary);
border-color: var(--primary);
color: #111;
color: var(--bg-card);
font-weight: 800;
}
@@ -154,7 +154,7 @@ function onPageSizeChange(e: Event) {
.page-size-select {
height: 32px;
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 6px;
@@ -166,7 +166,7 @@ function onPageSizeChange(e: Event) {
}
.page-size-select:hover {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
color: var(--text);
}
</style>

View File

@@ -64,23 +64,24 @@ function selectSport(id: SportCategory, enabled: boolean) {
padding: 7px 12px;
border-radius: 8px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.03);
background: rgba(0, 0, 0, 0.04);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
font-family: var(--font-heading);
transition: background 0.18s, border-color 0.18s, color 0.18s;
}
.sport-category:hover:not(:disabled) {
color: #fff;
background: rgba(255, 255, 255, 0.06);
color: var(--primary);
background: var(--bg-hover);
}
.sport-category.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: var(--border-gold-soft);
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.08);
background: rgba(0, 102, 204, 0.1);
border-color: var(--border-active);
box-shadow: inset 0 0 0 1px rgba(0, 102, 204, 0.08);
}
.sport-category.disabled {
@@ -100,9 +101,9 @@ function selectSport(id: SportCategory, enabled: boolean) {
.sport-category-soon {
padding: 1px 5px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
background: rgba(0, 0, 0, 0.04);
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.45);
color: rgba(0, 0, 0, 0.2);
}
</style>

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.4);
z-index: 210;
display: flex;
align-items: flex-end;
}
.drawer {
background: linear-gradient(180deg, #222 0%, #141414 100%);
background: #FFFFFF;
width: 100%;
max-height: 80vh;
border-radius: 16px 16px 0 0;
border-top: 1px solid var(--border);
border-top: 1px solid #E5E7EB;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -123,9 +123,9 @@ function formatOdds(odds: string) {
.drawer-foot {
flex-shrink: 0;
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
background: rgba(14, 14, 14, 0.98);
border-top: 1px solid var(--border-gold-soft);
box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.45);
background: #F5F7FA;
border-top: 1px solid #E5E7EB;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.drawer-header {
@@ -134,48 +134,50 @@ function formatOdds(odds: string) {
align-items: center;
padding: 14px 14px 12px;
flex-shrink: 0;
background: rgba(14, 14, 14, 0.98);
border-bottom: 1px solid #2a2a2a;
background: #FFFFFF;
border-bottom: 1px solid #E5E7EB;
z-index: 2;
}
.drawer-header h3 {
font-size: 16px;
font-weight: 800;
color: #1A1A2E;
}
.count {
color: var(--primary-light);
color: #003D6B;
}
.close-btn {
background: none;
color: var(--text-muted);
color: #6B7280;
font-size: 20px;
padding: 4px;
}
.slip-item {
padding: 10px 12px;
background: #111;
border: 1px solid var(--border);
border-radius: 6px;
background: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 8px;
margin-bottom: 8px;
}
.item-name {
font-size: 13px;
font-weight: 800;
color: #1A1A2E;
}
.item-sel {
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
margin-top: 4px;
}
.odds {
color: var(--primary-light);
color: #F8971F;
font-weight: 800;
}
@@ -188,24 +190,24 @@ function formatOdds(odds: string) {
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
margin-bottom: 8px;
}
.summary-row strong {
color: var(--primary-light);
color: #003D6B;
font-size: 15px;
font-weight: 800;
}
.return {
font-size: 13px;
color: var(--text-muted);
color: #6B7280;
}
.return strong {
color: var(--primary-light);
color: #003D6B;
font-size: 18px;
font-weight: 800;
}

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { groupCorrectScoreSelections, type CsSelection } from '../../utils/correctScoreLayout';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
const props = defineProps<{
marketType: string;
@@ -24,6 +25,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const { cancelClose } = useDesktopBetPopover();
const columns = computed(() =>
groupCorrectScoreSelections(
@@ -48,7 +50,11 @@ function formatOdds(odds: string) {
</script>
<template>
<div class="cs-panel" :class="{ 'cs-panel--locked': locked, 'cs-panel--dense': dense }">
<div
class="cs-panel"
:class="{ 'cs-panel--locked': locked, 'cs-panel--dense': dense }"
@mouseenter="cancelClose"
>
<div class="cols-head">
<span>{{ t('bet.col_home') }}</span>
<span>{{ t('bet.col_draw') }}</span>
@@ -65,6 +71,7 @@ function formatOdds(odds: string) {
:class="{ selected: isSelected(sel.id), 'score-card--locked': locked }"
:data-bet-selection-id="sel.id"
:disabled="locked"
@mouseenter="onPick(sel, $event)"
@click="onPick(sel, $event)"
>
<span class="score-line">{{ sel.scoreDisplay }}</span>
@@ -121,46 +128,54 @@ function formatOdds(odds: string) {
gap: 3px;
min-height: 42px;
padding: 6px 3px;
background: #161616;
border: 1px solid #282828;
border-radius: 3px;
background: #FFFFFF;
border: 1px solid #CBD5E1;
border-radius: 8px;
text-align: center;
transition: border-color 0.15s ease, background 0.15s ease;
cursor: pointer;
}
.score-card:hover:not(.score-card--locked) {
border-color: var(--primary-light);
background: var(--bg-hover);
}
.score-card.selected {
border-color: var(--primary);
background: rgba(212, 175, 55, 0.14);
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(0, 61, 107, 0.08);
}
.score-card.selected .score-line {
color: #e8d48a;
color: var(--primary);
}
.score-card.selected .odds {
color: #f0d875;
color: var(--odds-accent);
}
.score-card--locked {
background: #111;
border-color: #333;
background: #E8EDF2;
border-color: #E5E7EB;
cursor: not-allowed;
}
.score-card--locked .score-line,
.score-card--locked .odds {
color: #666;
color: #6B7280;
}
.score-line {
font-size: 11px;
font-weight: 800;
color: var(--text);
color: #1A1A2E;
}
.odds {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
color: var(--odds-accent);
}
.cs-panel--dense {
@@ -184,6 +199,7 @@ function formatOdds(odds: string) {
min-height: 34px;
padding: 4px 2px;
gap: 2px;
border-radius: 4px;
}
.cs-panel--dense .score-line {

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { resolveSelectionLabel } from '../../utils/selectionLabel';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
const props = defineProps<{
selections: {
@@ -13,9 +15,9 @@ const props = defineProps<{
}[];
isSelected: (id: string) => boolean;
compact?: boolean;
/** PC 行内横排:左标签右赔率,适合详情页全宽行 */
/** PC 琛屽唴妯帓锛氬乏鏍囩鍙宠禂鐜囷紝閫傚悎璇︽儏椤靛叏瀹借 */
horizontal?: boolean;
/** PC 卡片内:上标签下赔率,列数随选项数量 */
/** PC 鍗$墖鍐咃細涓婃爣绛句笅璧旂巼锛屽垪鏁伴殢閫夐」鏁伴噺 */
desktopCard?: boolean;
locked?: boolean;
lineValue?: string | number | null;
@@ -23,6 +25,7 @@ const props = defineProps<{
const emit = defineEmits<{ pick: [id: string, event?: MouseEvent] }>();
const { t } = useI18n();
const { cancelClose } = useDesktopBetPopover();
function label(sel: (typeof props.selections)[number]) {
if (sel.selectionDisplayName?.trim()) return sel.selectionDisplayName;
@@ -53,7 +56,7 @@ const panelStyle = computed(() =>
</script>
<template>
<div class="wrap" :class="{ compact, horizontal, desktopCard, locked }">
<div class="wrap" :class="{ compact, horizontal, desktopCard, locked }" @mouseenter="cancelClose">
<div
class="panel"
:class="{ horizontal, 'desktop-card': desktopCard }"
@@ -67,6 +70,7 @@ const panelStyle = computed(() =>
:class="{ selected: isSelected(sel.id), 'odds-btn--locked': locked }"
:data-bet-selection-id="sel.id"
:disabled="locked"
@mouseenter="onPick(sel.id, $event)"
@click="onPick(sel.id, $event)"
>
<span class="label">{{ label(sel) }}</span>
@@ -79,7 +83,7 @@ const panelStyle = computed(() =>
<style scoped>
.wrap {
padding: 6px 8px 8px;
background: #0c0c0c;
background: #F5F7FA;
}
.wrap.compact {
@@ -147,14 +151,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;
@@ -162,7 +166,7 @@ const panelStyle = computed(() =>
}
.panel.desktop-card .odds-btn .odds {
font-size: 13px;
font-size: 12px;
line-height: 1.1;
}
@@ -184,46 +188,54 @@ const panelStyle = computed(() =>
gap: 2px;
min-height: 44px;
padding: 6px 4px;
border-radius: 4px;
background: #141414;
border: 1px solid #262626;
border-radius: 8px;
background: #FFFFFF;
border: 1px solid #CBD5E1;
text-align: center;
transition: border-color 0.15s ease, background 0.15s ease;
cursor: pointer;
}
.odds-btn:hover:not(.odds-btn--locked) {
border-color: var(--primary-light);
background: var(--bg-hover);
}
.odds-btn.selected {
border-color: var(--primary);
background: rgba(212, 175, 55, 0.14);
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(0, 61, 107, 0.08);
}
.odds-btn.selected .label {
color: #e8d48a;
color: var(--primary);
}
.odds-btn.selected .odds {
color: #f0d875;
color: var(--odds-accent);
}
.odds-btn--locked {
background: #111;
border-color: #333;
background: #E8EDF2;
border-color: #E5E7EB;
cursor: not-allowed;
}
.odds-btn--locked .label,
.odds-btn--locked .odds {
color: #666;
color: #6B7280;
}
.label {
font-size: 9px;
color: var(--text-muted);
font-size: 10px;
color: #4B5563;
line-height: 1.15;
font-weight: 600;
}
.odds {
font-size: 14px;
font-size: 15px;
font-weight: 800;
color: var(--primary-light);
color: var(--odds-accent);
}
</style>

View File

@@ -45,11 +45,11 @@ const { t } = useI18n();
}
.row:not(.disabled):active {
background: rgba(255, 255, 255, 0.025);
background: rgba(0, 61, 107, 0.07);
}
.row.expanded {
background: rgba(255, 255, 255, 0.03);
background: rgba(0, 61, 107, 0.07);
}
.row.expanded::before {
@@ -58,9 +58,9 @@ const { t } = useI18n();
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: #003D6B;
opacity: 1;
}
.row.disabled {
@@ -72,7 +72,7 @@ const { t } = useI18n();
min-width: 0;
font-size: 13px;
font-weight: 700;
color: var(--text);
color: #1A1A2E;
letter-spacing: 0.02em;
}
@@ -80,11 +80,22 @@ const { t } = useI18n();
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
color: #ffb800;
color: #F8971F;
padding: 2px 6px;
border-radius: 3px;
background: rgba(255, 184, 0, 0.12);
border: 1px solid rgba(255, 184, 0, 0.35);
background: rgba(248, 151, 31, 0.1);
border: 1px solid rgba(248, 151, 31, 0.3);
}
.row-status {
flex-shrink: 0;
font-size: 9px;
font-weight: 700;
color: #f0b429;
padding: 2px 6px;
border-radius: 3px;
background: rgba(240, 180, 41, 0.12);
border: 1px solid rgba(240, 180, 41, 0.35);
}
.row-status {
@@ -99,12 +110,12 @@ const { t } = useI18n();
}
.row.expanded .row-label {
color: var(--primary-light);
color: #003D6B;
}
.row-muted {
font-size: 10px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
}
</style>

View File

@@ -437,12 +437,12 @@ function formatOdds(odds: string) {
position: relative;
width: 100%;
max-width: 300px;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px 16px 16px;
text-align: center;
box-shadow: var(--shadow), 0 0 24px rgba(212, 175, 55, 0.08);
box-shadow: var(--shadow-md);
}
.close-x {
@@ -452,7 +452,7 @@ function formatOdds(odds: string) {
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
background: var(--bg-hover);
color: var(--text-muted);
font-size: 14px;
line-height: 1;
@@ -545,7 +545,7 @@ function formatOdds(odds: string) {
align-items: center;
padding: 8px 10px;
margin-bottom: 12px;
background: rgba(0, 0, 0, 0.35);
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 6px;
}
@@ -577,7 +577,7 @@ function formatOdds(odds: string) {
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
background: var(--bg-card);
color: var(--text);
font-size: 18px;
font-weight: 800;
@@ -586,12 +586,12 @@ function formatOdds(odds: string) {
}
.stake-input:focus {
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.15);
border-color: var(--border-active);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
.stake-input::placeholder {
color: #555;
color: var(--text-muted);
font-weight: 500;
font-size: 14px;
}
@@ -607,16 +607,16 @@ function formatOdds(odds: string) {
padding: 6px 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: #141414;
background: var(--bg-hover);
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
}
.chip:active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
border-color: var(--border-active);
color: var(--primary);
background: rgba(0, 102, 204, 0.08);
}
.est-return {
@@ -646,7 +646,7 @@ function formatOdds(odds: string) {
.btn-cancel {
padding: 10px;
border-radius: 6px;
background: #1a1a1a;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 14px;
@@ -687,7 +687,7 @@ function formatOdds(odds: string) {
.summary {
margin: 12px 0 14px;
padding: 10px;
background: rgba(0, 0, 0, 0.35);
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 6px;
}

View File

@@ -3,9 +3,6 @@ import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import OutrightOptionCard from './OutrightOptionCard.vue';
import saishiImg from '../../assets/images/saishi.webp';
import cardBg from '../../assets/images/card-bg.webp';
const matchCardBg = `url(${cardBg})`;
export interface OutrightSelection {
id: string;
@@ -117,23 +114,13 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
cursor: pointer;
}
.event-head::before {
content: '';
position: absolute;
inset: 0;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
opacity: 0.25;
z-index: -1;
pointer-events: none;
}
.toggle-icon {
flex-shrink: 0;
width: 26px;
height: 26px;
border-radius: 50%;
background: #141414;
border: 1px solid var(--border-gold-soft);
background: var(--bg-hover);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
@@ -170,8 +157,8 @@ const isSettled = computed(() => props.event.bettingOpen === false || props.even
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
color: #c9a227;
border: 1px solid rgba(201, 162, 39, 0.45);
color: var(--odds-accent);
border: 1px solid rgba(248, 151, 31, 0.45);
border-radius: 999px;
padding: 1px 7px;
line-height: 1.4;

View File

@@ -86,42 +86,37 @@ onUnmounted(() => {
position: relative;
width: 100%;
min-width: 0;
padding: 8px 4px 6px;
border-radius: 6px;
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
border: 1px solid rgba(140, 140, 140, 0.35);
padding: 10px 4px 8px;
border-radius: 8px;
background: var(--bg-card);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
gap: 6px;
text-align: center;
overflow: hidden;
transition: all 0.2s ease;
cursor: pointer;
}
.option-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(100, 100, 100, 0.08) 0%, transparent 50%, rgba(80, 80, 80, 0.05) 100%);
pointer-events: none;
.option-card:hover:not(.option-card--disabled) {
border-color: var(--border-active);
background: var(--bg-hover) !important;
}
.option-card:active {
border-color: var(--border-gold-soft);
.option-card:active:not(.option-card--disabled) {
border-color: var(--primary);
}
.option-card--disabled {
cursor: default;
opacity: 0.72;
}
.option-card--disabled:active {
border-color: rgba(140, 140, 140, 0.35);
opacity: 0.6;
}
.option-card--winner {
border-color: rgba(201, 162, 39, 0.75);
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.25);
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.flag {
@@ -143,8 +138,8 @@ onUnmounted(() => {
.name {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
font-weight: 700;
color: var(--text);
line-height: 1.2;
width: 100%;
overflow: hidden;
@@ -153,8 +148,8 @@ onUnmounted(() => {
}
.odds {
font-size: 11px;
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
color: var(--odds-accent);
}
</style>

View File

@@ -149,7 +149,7 @@ function closeModal() {
border: 1px solid rgba(201, 162, 39, 0.22);
font-size: 12px;
font-weight: 600;
color: #c9a227;
color: var(--odds-accent);
line-height: 1.45;
}

View File

@@ -5,27 +5,80 @@ const visible = ref(false);
const anchorX = ref(0);
const anchorY = ref(0);
const pendingItem = ref<SlipItem | null>(null);
const activeCardRootId = ref<string | null>(null);
const placement = ref<'top' | 'bottom'>('bottom');
let closeTimer: number | undefined;
export function useDesktopBetPopover() {
function openAt(event: MouseEvent, item: SlipItem) {
cancelClose();
const el = (event.currentTarget || event.target) as HTMLElement;
const btn = el?.closest?.('button') || el;
const rect = btn?.getBoundingClientRect?.();
if (!rect) return;
const popW = 172;
const popH = 155;
const pad = 12;
const popW = 300;
const popH = 320;
let x = event.clientX + pad;
let y = event.clientY + pad;
const btnCenterX = rect.left + rect.width / 2;
let x = btnCenterX - popW / 2;
let y = rect.bottom + pad;
placement.value = 'bottom';
if (typeof window !== 'undefined') {
x = Math.min(x, window.innerWidth - popW - pad);
y = Math.min(y, window.innerHeight - popH - pad);
const spaceBelow = window.innerHeight - rect.bottom;
if (spaceBelow < popH + pad) {
y = rect.top - popH - pad;
placement.value = 'top';
}
x = Math.max(pad, Math.min(x, window.innerWidth - popW - pad));
y = Math.max(pad, Math.min(y, window.innerHeight - popH - pad));
}
anchorX.value = Math.max(pad, x);
anchorY.value = Math.max(pad, y);
anchorX.value = x;
anchorY.value = y;
pendingItem.value = item;
const root = el?.closest?.('[data-bet-card-root]') as HTMLElement | null;
activeCardRootId.value = root?.dataset.betCardRoot ?? null;
visible.value = true;
}
function close() {
visible.value = false;
pendingItem.value = null;
activeCardRootId.value = null;
if (closeTimer !== undefined) {
window.clearTimeout(closeTimer);
closeTimer = undefined;
}
}
function scheduleClose(delay = 200) {
if (closeTimer !== undefined) {
window.clearTimeout(closeTimer);
}
closeTimer = window.setTimeout(() => {
visible.value = false;
pendingItem.value = null;
activeCardRootId.value = null;
closeTimer = undefined;
}, delay);
}
function cancelClose() {
if (closeTimer !== undefined) {
window.clearTimeout(closeTimer);
closeTimer = undefined;
}
}
function isActiveForCard(cardRootId: string | null | undefined) {
if (!cardRootId || !visible.value || !pendingItem.value) return false;
return activeCardRootId.value === cardRootId;
}
return {
@@ -33,7 +86,12 @@ export function useDesktopBetPopover() {
anchorX,
anchorY,
pendingItem,
activeCardRootId,
placement,
openAt,
close,
scheduleClose,
cancelClose,
isActiveForCard,
};
}

View File

@@ -1,12 +1,24 @@
import { ref } from 'vue';
import { ref, watch } from 'vue';
const searchQuery = ref('');
const filterState = ref({
time: 'all' as 'all' | 'today' | 'early',
status: 'open' as 'all' | 'open' | 'settled',
leagueIds: [] as string[]
leagueIds: [] as string[],
});
/** 空数组表示未限定联赛,等价于全部选中 */
export function isLeagueSelected(leagueId: string, leagueIds: string[]) {
return leagueIds.length === 0 || leagueIds.includes(leagueId);
}
watch(
() => filterState.value.time,
() => {
filterState.value.leagueIds = [];
},
);
export function useMatchFilters() {
function clearFilters() {
searchQuery.value = '';
@@ -15,12 +27,23 @@ export function useMatchFilters() {
filterState.value.leagueIds = [];
}
function toggleLeague(leagueId: string) {
const idx = filterState.value.leagueIds.indexOf(leagueId);
function toggleLeague(leagueId: string, availableIds: string[]) {
const current = filterState.value.leagueIds;
if (current.length === 0) {
filterState.value.leagueIds = availableIds.filter((id) => id !== leagueId);
return;
}
const idx = current.indexOf(leagueId);
if (idx > -1) {
filterState.value.leagueIds.splice(idx, 1);
} else {
filterState.value.leagueIds.push(leagueId);
current.splice(idx, 1);
return;
}
current.push(leagueId);
if (availableIds.every((id) => current.includes(id))) {
filterState.value.leagueIds = [];
}
}
@@ -29,5 +52,6 @@ export function useMatchFilters() {
filterState,
clearFilters,
toggleLeague,
isLeagueSelected,
};
}

View File

@@ -6,6 +6,27 @@ import { resolveBanners } from '../constants/defaultBanner';
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
import { stripHtml } from '../utils/html';
export interface PlayerHomeMatchSelection {
id: string;
selectionCode?: string;
selectionName: string;
selectionDisplayName?: string;
odds: string;
status?: string;
oddsVersion?: string;
}
export interface PlayerHomeMatchMarket {
id: string;
marketType: string;
marketDisplayName?: string;
lineValue?: number | null;
status: string;
allowSingle?: boolean;
allowParlay?: boolean;
selections: PlayerHomeMatchSelection[];
}
export interface PlayerHomeMatch {
id: string;
leagueName?: string;
@@ -19,6 +40,8 @@ export interface PlayerHomeMatch {
startTime: string;
isHot?: boolean;
displayOrder?: number;
bettingOpen?: boolean;
markets?: PlayerHomeMatchMarket[];
}
export interface PlayerContentItem {

View File

@@ -0,0 +1,45 @@
import { computed, toValue, type MaybeRefOrGetter } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const FALLBACK_RULES: Array<{ test: (path: string) => boolean; fallback: string }> = [
{ test: (p) => /^\/wallet\/transactions\//.test(p), fallback: '/wallet/detail' },
{ test: (p) => p.startsWith('/wallet/recharge/history'), fallback: '/wallet' },
{ test: (p) => p === '/wallet/recharge', fallback: '/wallet' },
{ test: (p) => p === '/wallet/detail', fallback: '/wallet' },
{ test: (p) => p === '/wallet/cashbacks' || p === '/profile/cashbacks', fallback: '/wallet' },
{ test: (p) => p === '/profile/edit', fallback: '/wallet' },
{ test: (p) => p.startsWith('/bets/'), fallback: '/bets' },
{ test: (p) => p.startsWith('/match/'), fallback: '/bet' },
{ test: (p) => p.startsWith('/outright/'), fallback: '/bet' },
{ test: (p) => p.startsWith('/announcements/'), fallback: '/announcements' },
{ test: (p) => p.startsWith('/messages/'), fallback: '/messages' },
{ test: (p) => p === '/wallet', fallback: '/' },
{ test: (p) => p === '/bets', fallback: '/' },
];
export function resolveSmartBackFallback(path: string): string {
for (const rule of FALLBACK_RULES) {
if (rule.test(path)) return rule.fallback;
}
return '/';
}
export function useSmartBack(fallback?: MaybeRefOrGetter<string | undefined>) {
const router = useRouter();
const route = useRoute();
const resolvedFallback = computed(
() => toValue(fallback) ?? resolveSmartBackFallback(route.path),
);
function goBack() {
const state = window.history.state as { back?: string | null } | null;
if (state?.back != null) {
router.back();
return;
}
void router.push(resolvedFallback.value);
}
return { goBack, fallback: resolvedFallback };
}

View File

@@ -29,9 +29,12 @@ export default {
total: '{total} total',
per_page: '{size} / page',
},
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' },
nav: { home: 'Home', sports: 'Sports', football: 'Football', bet: 'Bet', bet_history: 'History', wallet: 'Transactions', my_wallet: 'My Wallet', account_hub: 'Bets & Wallet', profile: 'Profile', search: 'Search teams, leagues…', announcements: 'Announcements', sports_more_soon: 'More sports coming soon' },
home: {
hot_matches: 'Hot matches',
section_featured: 'Featured',
section_upcoming: 'Upcoming',
sidebar_recommend: 'Recommended',
hot_tab: 'Hot',
upcoming_tab: 'Upcoming',
no_matches: 'No matches',
@@ -43,6 +46,9 @@ export default {
banner_next: 'Next slide',
banner_slide: 'Slide {n}',
banner_fallback: 'Banner',
bento_desc: 'Explore hot events worldwide',
view_all_matches: 'View all matches',
view_bet_history: 'View bet history',
},
announcements: {
title: 'Announcements',
@@ -158,6 +164,7 @@ export default {
},
auth:
{ login: 'Login',
logging_in: 'Logging in…',
register: 'Create Account',
logout: 'Log out',
username: 'Username',
@@ -212,6 +219,7 @@ export default {
forgot_password: 'Forgot password?',
forgot_password_title: 'Reset password',
reset_password_btn: 'Reset password',
resetting_password: 'Resetting…',
reset_success: 'Password reset. Please sign in with your new password. Contact your agent if you cannot log in.',
reset_failed: 'Reset failed. Please try again.',
phone_not_registered: 'This phone number is not registered',
@@ -227,6 +235,7 @@ export default {
},
wallet: {
balance: 'Balance',
my_balance: 'My Balance',
cash_balance: 'Cash Balance',
card_holder: 'Cardholder',
unsettled: 'Unsettled',
@@ -263,6 +272,9 @@ export default {
filter_bet: 'Bet',
filter_cashback: 'Cashback',
view_all: 'View all transactions',
recent_title: 'Recent transactions',
overview: 'Overview',
all_records: 'All transactions',
detail_summary: 'Details',
detail_amount: 'Amount',
detail_balance_before: 'Balance Before',
@@ -293,6 +305,8 @@ export default {
transaction_detail: 'Transaction details',
balance_before: 'Balance before',
balance_after: 'Balance after',
frozen_before: 'Frozen before',
frozen_after: 'Frozen after',
transaction_id: 'Transaction ID',
detail_title: 'Wallet details',
recharge_btn: 'Deposit',
@@ -394,16 +408,18 @@ export default {
sport_coming_soon: 'Coming soon',
tab_today: 'Today',
tab_early: 'Early',
filter_time: 'Time',
filter_time: 'Filter',
filter_league: 'Leagues',
filter_market: 'Markets',
time_all: 'All',
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_market_odds: 'No odds available',
no_markets_filtered: 'No markets match the current filter',
popover_title: 'Confirm bet',
place_now: 'Bet now',
@@ -446,6 +462,9 @@ export default {
outright_load_more: 'Load more',
outright_settled: 'Settled',
outright_settled_hint: 'This event is settled. Odds and results are view-only.',
quick_bet: 'Quick bet',
popular_teams: 'Popular teams',
outright_view_all: 'View all teams',
cancel: 'Cancel',
parlay_max_legs: 'Parlay allows up to 5 legs',
parlay_block_outright: 'Outright cannot be parlayed',

View File

@@ -36,6 +36,7 @@ export default {
bet: 'Pertaruhan',
bet_history: 'Sejarah',
wallet: 'Transaksi',
my_wallet: 'Dompet Saya',
account_hub: 'Pertaruhan & Bil',
profile: 'Profil',
search: 'Cari pasukan, liga…',
@@ -44,6 +45,9 @@ export default {
},
home: {
hot_matches: 'Perlawanan popular',
section_featured: 'Perlawanan utama',
section_upcoming: 'Akan bermula',
sidebar_recommend: 'Disyorkan',
hot_tab: 'Popular',
upcoming_tab: 'Terdekat',
no_matches: 'Tiada perlawanan',
@@ -55,6 +59,9 @@ export default {
banner_next: 'Slaid seterusnya',
banner_slide: 'Slaid {n}',
banner_fallback: 'Banner',
bento_desc: 'Teroka acara sukan hangat',
view_all_matches: 'Lihat semua perlawanan',
view_bet_history: 'Lihat sejarah pertaruhan',
},
announcements: {
title: 'Pusat Pengumuman',
@@ -170,6 +177,7 @@ export default {
},
auth: {
login: 'Log Masuk',
logging_in: 'Sedang log masuk…',
register: 'Daftar Akaun',
logout: 'Log Keluar',
username: 'Nama Pengguna',
@@ -224,6 +232,7 @@ export default {
forgot_password: 'Lupa kata laluan?',
forgot_password_title: 'Tetapkan semula kata laluan',
reset_password_btn: 'Tetapkan semula',
resetting_password: 'Sedang tetapkan semula…',
reset_success: 'Kata laluan ditetapkan semula. Sila log masuk dengan kata laluan baharu. Hubungi ejen anda jika tidak dapat log masuk.',
reset_failed: 'Gagal menetapkan semula. Sila cuba lagi.',
phone_not_registered: 'Nombor telefon ini belum didaftarkan',
@@ -239,6 +248,7 @@ export default {
},
wallet: {
balance: 'Baki',
my_balance: 'Baki Saya',
cash_balance: 'Baki Tunai',
card_holder: 'Pemegang',
unsettled: 'Belum Selesai',
@@ -275,6 +285,9 @@ export default {
filter_bet: 'Pertaruhan',
filter_cashback: 'Rebat',
view_all: 'Lihat semua transaksi',
recent_title: 'Transaksi terkini',
overview: 'Gambaran akaun',
all_records: 'Semua transaksi',
detail_summary: 'Butiran',
detail_amount: 'Jumlah',
detail_balance_before: 'Baki Sebelum',
@@ -305,6 +318,8 @@ export default {
transaction_detail: 'Butiran transaksi',
balance_before: 'Baki sebelum',
balance_after: 'Baki selepas',
frozen_before: 'Beku sebelum',
frozen_after: 'Beku selepas',
transaction_id: 'ID transaksi',
detail_title: 'Butiran bil',
recharge_btn: 'Topup',
@@ -406,16 +421,18 @@ export default {
sport_coming_soon: 'Akan datang',
tab_today: 'Hari Ini',
tab_early: 'Awal',
filter_time: 'Masa',
filter_time: 'Tapis',
filter_league: 'Liga',
filter_market: 'Pasaran',
time_all: 'Semua',
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_market_odds: 'Tiada odds tersedia',
no_markets_filtered: 'Tiada pasaran sepadan dengan penapis semasa',
popover_title: 'Sahkan pertaruhan',
place_now: 'Pertaruhan sekarang',
@@ -458,6 +475,9 @@ export default {
outright_load_more: 'Muat lagi',
outright_settled: 'Selesai',
outright_settled_hint: 'Acara ini telah diselesaikan. Hanya paparan odds dan keputusan.',
quick_bet: 'Pertaruhan pantas',
popular_teams: 'Pasukan popular',
outright_view_all: 'Lihat semua pasukan',
cancel: 'Batal',
parlay_max_legs: 'Maksimum 5 pilihan parlay',
parlay_block_outright: 'Outright tidak boleh parlay',

View File

@@ -29,9 +29,12 @@ export default {
total: '共 {total} 条',
per_page: '{size} / 页',
},
nav: { home: '主页', sports: '体育赛事', football: '足球', bet: '投注', bet_history: '历史投注', wallet: '资金明细', account_hub: '投注账单', profile: '我的', search: '搜索球队、联赛…', announcements: '公告', sports_more_soon: '更多体育,敬请期待' },
nav: { home: '主页', sports: '体育赛事', football: '足球', bet: '投注', bet_history: '历史投注', wallet: '资金明细', my_wallet: '我的钱包', account_hub: '投注账单', profile: '我的', search: '搜索球队、联赛…', announcements: '公告', sports_more_soon: '更多体育,敬请期待' },
home: {
hot_matches: '热门赛事',
section_featured: '焦点赛事',
section_upcoming: '即将开赛',
sidebar_recommend: '推荐赛事',
hot_tab: '热门',
upcoming_tab: '近期',
no_matches: '暂无赛事',
@@ -43,6 +46,9 @@ export default {
banner_next: '下一张',
banner_slide: '第 {n} 张',
banner_fallback: 'Banner',
bento_desc: '探索全球热门体育赛事',
view_all_matches: '查看全部赛事',
view_bet_history: '查看历史投注',
},
announcements: {
title: '公告中心',
@@ -158,6 +164,7 @@ export default {
},
auth: {
login: '登录',
logging_in: '登录中…',
register: '注册账号',
logout: '退出登录',
username: '账号',
@@ -212,6 +219,7 @@ export default {
forgot_password: '忘记密码?',
forgot_password_title: '重置密码',
reset_password_btn: '重置密码',
resetting_password: '重置中…',
reset_success: '密码已重置,请使用新密码登录。若无法登录请联系代理。',
reset_failed: '重置失败,请重试',
phone_not_registered: '该手机号未注册',
@@ -227,6 +235,7 @@ export default {
},
wallet: {
balance: '余额',
my_balance: '我的余额',
cash_balance: '现金余额',
card_holder: '持卡人',
unsettled: '未结算',
@@ -263,6 +272,9 @@ export default {
filter_bet: '投注',
filter_cashback: '反水',
view_all: '查看全部账单',
recent_title: '最近账单',
overview: '账户概览',
all_records: '全部账单',
detail_summary: '账务明细',
detail_amount: '变动金额',
detail_balance_before: '变动前余额',
@@ -293,6 +305,8 @@ export default {
transaction_detail: '交易详情',
balance_before: '变动前余额',
balance_after: '变动后余额',
frozen_before: '变动前冻结',
frozen_after: '变动后冻结',
transaction_id: '流水号',
detail_title: '账单明细',
recharge_btn: '充值',
@@ -394,16 +408,18 @@ export default {
sport_coming_soon: '即将上线',
tab_today: '今日',
tab_early: '早盘',
filter_time: '时间筛选',
filter_time: '筛选',
filter_league: '联赛',
filter_market: '玩法筛选',
time_all: '全部',
all: '全部',
time_today: '今天',
time_early: '早盘',
market_all: '所有盘口',
market_handicap_ou: '让球 / 大小',
correct_score: '波胆',
no_markets: '暂无可用盘口',
no_market_odds: '暂无盘口赔率',
no_markets_filtered: '当前筛选下暂无盘口',
popover_title: '确认投注',
place_now: '立即下注',
@@ -446,6 +462,9 @@ export default {
outright_load_more: '加载更多',
outright_settled: '已结算',
outright_settled_hint: '本赛事已结算,仅可查看赔率与冠军结果',
quick_bet: '快速下注',
popular_teams: '热门队伍',
outright_view_all: '查看全部队伍',
cancel: '取消',
parlay_max_legs: '串关最多 5 项',
parlay_block_outright: '冠军盘不可串关',

View File

@@ -12,6 +12,7 @@ import BackToTopButton from '../components/BackToTopButton.vue';
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
import { useViewport } from '../composables/useViewport';
import DesktopShell from '../components/desktop/DesktopShell.vue';
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
import { usePlayerHome } from '../composables/usePlayerHome';
@@ -43,6 +44,9 @@ const isDetailPage = computed(() => {
});
const showHeader = computed(() => !isDetailPage.value);
const showAnnouncement = computed(
() => !isDetailPage.value && !route.path.startsWith('/profile') && !route.path.startsWith('/announcements'),
);
const showBottomNav = computed(() => {
const p = route.path;
@@ -54,18 +58,19 @@ const showBottomNav = computed(() => {
p.startsWith('/match/') ||
p === '/bets' ||
p === '/wallet' ||
p === '/profile'
p.startsWith('/wallet/')
) return true;
// 邮箱关闭时客服页保留底部导航,避免用户无法离开
if (!inboxEnabled.value && p === '/messages') return true;
return false;
});
const { load: loadPlayerHome } = usePlayerHome();
const { announcements, announcementItems, load: loadPlayerHome } = usePlayerHome();
const { loadProfile, refreshProfile, bindProfileVisibilityRefresh } = usePlayerProfile();
const { startPolling, stopPolling } = useDepositNotifications();
const { unreadCount, refreshUnreadCount, resetMessagesState } = usePlayerMessages();
const mainRef = ref<HTMLElement | null>(null);
const tabScrollTops = new Map<string, number>();
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
const bottomNavHidden = ref(false);
let lastMainScrollTop = 0;
@@ -103,7 +108,7 @@ watch(
},
);
const balanceRefreshPaths = ['/profile', '/wallet', '/bets'];
const balanceRefreshPaths = ['/wallet', '/bets'];
let mainScrollEl: HTMLElement | null = null;
watch(locale, (next, prev) => {
@@ -160,11 +165,15 @@ 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="#6B7280" 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="#003D6B" 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="#F8971F" letter-spacing="0.01em">365</text>
</svg>
<div class="header-actions">
<RouterLink
:to="hubRoute"
class="hub-btn"
class="support-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">
@@ -181,6 +190,7 @@ watch(
</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 />
@@ -192,7 +202,15 @@ watch(
</div>
</header>
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav }]">
<div v-if="showAnnouncement" class="announce-strip">
<AnnouncementMarquee
:items="announcements"
:target-id="primaryAnnouncementId"
embedded
/>
</div>
<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" />
@@ -223,13 +241,13 @@ watch(
<BottomNavIcon name="history" />
<span class="nav-label">{{ t('nav.bet_history') }}</span>
</RouterLink>
<RouterLink to="/wallet" class="nav-item" :class="{ active: route.path === '/wallet' }">
<RouterLink
to="/wallet"
class="nav-item"
:class="{ active: route.path === '/wallet' || route.path.startsWith('/wallet/') }"
>
<BottomNavIcon name="wallet" />
<span class="nav-label">{{ t('nav.wallet') }}</span>
</RouterLink>
<RouterLink to="/profile" class="nav-item" :class="{ active: route.path.startsWith('/profile') }">
<BottomNavIcon name="profile" />
<span class="nav-label">{{ t('nav.profile') }}</span>
<span class="nav-label">{{ t('nav.my_wallet') }}</span>
</RouterLink>
</nav>
@@ -249,7 +267,7 @@ watch(
display: flex;
flex-direction: column;
overflow: hidden;
background: transparent;
background: var(--bg-body);
box-sizing: border-box;
padding-top: var(--safe-top);
}
@@ -260,17 +278,19 @@ watch(
justify-content: space-between;
align-items: center;
padding: 8px 12px;
min-height: var(--player-header-h);
background: rgba(17, 17, 17, 0.94);
min-height: var(--player-header-h, 48px);
background: #FFFFFF;
border-bottom: 1px solid var(--border);
z-index: 120;
}
.logo {
height: 36px; width: auto; display: block;
filter: drop-shadow(0 0 4px rgba(212, 175, 55, 0.2));
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;
@@ -286,28 +306,43 @@ watch(
width: var(--header-chip-h);
}
.hub-btn {
position: relative;
.header-divider {
width: 1px;
height: 18px;
background: #E5E7EB;
flex-shrink: 0;
margin: 0 2px;
}
.support-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-gold-soft, rgba(200, 168, 78, 0.25));
background: rgba(200, 168, 78, 0.08);
color: var(--primary-light, #c8a84e);
gap: 0;
width: var(--header-chip-h, 32px);
height: var(--header-chip-h, 32px);
padding: 0;
border-radius: 50%;
border: 1px solid var(--border);
background: #F5F7FA;
color: var(--primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
flex-shrink: 0;
position: relative;
text-decoration: none;
}
.hub-btn:active {
background: rgba(200, 168, 78, 0.16);
.support-btn:active {
background: var(--bg-hover);
border-color: var(--border-active);
}
.hub-icon {
width: 18px;
height: 18px;
.support-icon, .hub-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.hub-badge {
@@ -315,37 +350,41 @@ watch(
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #e74c3c;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 10px;
font-weight: 800;
font-weight: 700;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px rgba(17, 17, 17, 0.94);
pointer-events: none;
}
.support-label {
display: none;
}
.login-btn {
padding: 6px 16px;
border-radius: 6px;
border: 1px solid var(--primary, #c8a84e);
background: transparent;
color: var(--primary-light, #c8a84e);
padding: 6px 18px;
border-radius: var(--radius-sm);
border: 1px solid var(--primary);
background: var(--primary);
color: #FFFFFF;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
transition: background 0.15s;
}
.login-btn:active {
background: var(--primary-dark);
}
.login-btn:active {
background: rgba(200, 168, 78, 0.15);
}
.announce-strip {
flex-shrink: 0;
z-index: 105;
background: #FFFFFF;
position: relative;
z-index: 100;
border-bottom: 1px solid var(--border);
}
@@ -355,12 +394,14 @@ watch(
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 12px 16px 0;
padding: 10px 14px 0;
}
.main.has-nav {
padding-bottom: calc(var(--player-bottom-nav-h) + var(--safe-bottom));
}
.main.main--detail {
padding-top: 0;
}
.bottom-nav {
position: fixed;
@@ -368,12 +409,11 @@ watch(
right: 0;
bottom: 0;
display: flex;
background: #FFFFFF;
box-sizing: border-box;
min-height: calc(var(--player-bottom-nav-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
background: rgba(17, 17, 17, 0.96);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35);
z-index: 100;
transition: transform 0.28s ease, opacity 0.28s ease;
will-change: transform;
@@ -391,15 +431,16 @@ watch(
align-items: center;
justify-content: center;
gap: 3px;
padding: 6px 2px 4px;
font-size: 9px;
padding: 7px 2px 8px;
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
letter-spacing: 0.02em;
font-weight: 500;
letter-spacing: 0.01em;
position: relative;
transition: color 0.2s;
line-height: 1.2;
min-width: 0;
text-decoration: none;
}
.nav-label {
@@ -411,22 +452,23 @@ watch(
}
.nav-item.active {
color: var(--primary-light);
font-weight: 800;
color: var(--primary);
font-weight: 700;
}
.nav-item.active :deep(.nav-icon) {
filter: drop-shadow(0 0 4px rgba(212, 175, 55, 0.35));
color: var(--primary);
filter: drop-shadow(0 1px 2px rgba(0, 61, 107, 0.2));
}
.nav-item.active::after {
content: '';
position: absolute;
top: 0;
left: 22%;
right: 22%;
height: 2px;
left: 18%;
right: 18%;
height: 2.5px;
background: var(--primary);
border-radius: 0 0 4px 4px;
border-radius: 0 0 3px 3px;
}
</style>
</style>

View File

@@ -15,25 +15,25 @@ const router = createRouter({
{ path: '', component: () => import('../views/HomeView.vue'), meta: { keepAlive: true } },
{ path: 'bet', component: () => import('../views/FootballView.vue'), meta: { keepAlive: true } },
{ path: 'football', redirect: '/bet' },
{
path: 'announcements',
component: () => import('../views/AnnouncementsHubView.vue'),
children: [
{ path: '', component: () => import('../views/AnnouncementListView.vue'), meta: { keepAlive: true } },
{ path: ':id', component: () => import('../views/AnnouncementDetailView.vue') },
],
},
{
path: 'messages',
component: () => import('../views/MessagesHubView.vue'),
meta: { keepAlive: true, requiresAuth: false },
children: [
{ path: '', component: () => import('../views/InboxHubView.vue') },
{ path: ':id', component: () => import('../views/MessageDetailView.vue'), meta: { requiresAuth: true } },
],
},
{ path: 'match/:id', component: () => import('../views/MatchDetailView.vue') },
{ path: 'outright/:id', component: () => import('../views/OutrightDetailView.vue') },
{
path: 'announcements',
component: () => import('../views/AnnouncementsHubView.vue'),
children: [
{ path: '', component: () => import('../views/AnnouncementListView.vue'), meta: { keepAlive: true } },
{ path: ':id', component: () => import('../views/AnnouncementDetailView.vue') },
],
},
{
path: 'messages',
component: () => import('../views/MessagesHubView.vue'),
meta: { keepAlive: true, requiresAuth: false },
children: [
{ path: '', component: () => import('../views/InboxHubView.vue') },
{ path: ':id', component: () => import('../views/MessageDetailView.vue'), meta: { requiresAuth: true } },
],
},
{ path: 'match/:id', component: () => import('../views/MatchDetailView.vue') },
{ path: 'outright/:id', component: () => import('../views/OutrightDetailView.vue') },
// 需要登录的页面
{ path: 'bets', component: () => import('../views/MyBetsView.vue'), meta: { keepAlive: true, requiresAuth: true } },
{ path: 'bets/:betNo', component: () => import('../views/BetDetailView.vue'), meta: { requiresAuth: true } },
@@ -42,9 +42,10 @@ const router = createRouter({
{ path: 'wallet/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/recharge', component: () => import('../views/RechargeView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/recharge/history', component: () => import('../views/RechargeHistoryView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/recharge/history/:id', component: () => import('../views/RechargeDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'wallet/transactions/:transactionId', component: () => import('../views/WalletTransactionDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'profile', component: () => import('../views/ProfileView.vue'), meta: { keepAlive: true, requiresAuth: true } },
{ path: 'profile/cashbacks', component: () => import('../views/CashbackRecordsView.vue'), meta: { requiresAuth: true } },
{ path: 'profile', redirect: '/wallet' },
{ path: 'profile/cashbacks', redirect: '/wallet/cashbacks' },
{ path: 'profile/edit', component: () => import('../views/ProfileEditView.vue'), meta: { requiresAuth: true } },
],
},

View File

@@ -27,6 +27,23 @@ export type SlipMode = 'single' | 'parlay';
export type ParlaySlipError = ParlayRejectReason | 'MAX_LEGS' | 'SAME_MATCH';
const BET_SLIP_STORAGE_PREFIX = 'player:betSlip:v1';
const DESKTOP_PANEL_EXPANDED_KEY = 'player:desktopBetSlipExpanded:v1';
function readDesktopPanelExpanded() {
try {
return localStorage.getItem(DESKTOP_PANEL_EXPANDED_KEY) !== '0';
} catch {
return true;
}
}
function writeDesktopPanelExpanded(expanded: boolean) {
try {
localStorage.setItem(DESKTOP_PANEL_EXPANDED_KEY, expanded ? '1' : '0');
} catch {
// ignore quota / private mode errors
}
}
interface BetSlipPersisted {
singleItem: SlipItem | null;
@@ -238,7 +255,7 @@ export const useBetSlipStore = defineStore('betSlip', () => {
function addToSingleCart(item: SlipItem) {
mode.value = 'single';
if (singleCartItems.value.some((i) => i.selectionId === item.selectionId)) return;
singleCartItems.value.push(item);
singleCartItems.value.unshift(item);
initItemStake(item.selectionId);
lastParlayError.value = null;
}
@@ -281,7 +298,7 @@ export const useBetSlipStore = defineStore('betSlip', () => {
return 'MAX_LEGS';
}
parlayItems.value.push(item);
parlayItems.value.unshift(item);
lastParlayError.value = null;
return null;
}
@@ -370,6 +387,11 @@ export const useBetSlipStore = defineStore('betSlip', () => {
);
const drawerOpen = ref(false);
const panelExpanded = ref(readDesktopPanelExpanded());
watch(panelExpanded, (expanded) => {
writeDesktopPanelExpanded(expanded);
});
function openDrawer() {
drawerOpen.value = true;
@@ -379,6 +401,18 @@ export const useBetSlipStore = defineStore('betSlip', () => {
drawerOpen.value = false;
}
function expandPanel() {
panelExpanded.value = true;
}
function collapsePanel() {
panelExpanded.value = false;
}
function togglePanelExpanded() {
panelExpanded.value = !panelExpanded.value;
}
function updateSelectionOdds(selectionId: string, odds: number, oddsVersion: string) {
if (singleItem.value?.selectionId === selectionId) {
singleItem.value = { ...singleItem.value, odds, oddsVersion };
@@ -414,6 +448,7 @@ export const useBetSlipStore = defineStore('betSlip', () => {
canSubmit,
lastParlayError,
drawerOpen,
panelExpanded,
setMode,
setSingleItem,
addItem,
@@ -428,6 +463,9 @@ export const useBetSlipStore = defineStore('betSlip', () => {
clearAll,
openDrawer,
closeDrawer,
expandPanel,
collapsePanel,
togglePanelExpanded,
updateSelectionOdds,
getItemStake,
setItemStake,

View File

@@ -1,230 +1,71 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--primary: #D4AF37;
--primary-dark: #A8841F;
--primary-light: #F0D875;
--secondary: #1A1A1A;
--tertiary: #000000;
--neutral: #8E8E93;
--bg-body: #000000;
--bg-card: rgba(20, 20, 20, 0.65);
--bg-hover: rgba(34, 34, 34, 0.65);
--bg-elevated: rgba(42, 42, 42, 0.65);
--text: #FFFFFF;
--text-muted: #8E8E93;
--border: #333333;
--border-gold: #D4AF37;
--border-gold-soft: rgba(212, 175, 55, 0.28);
/* Pinnacle-inspired: Navy blue + clean white theme */
--primary: #003D6B;
--primary-dark: #002847;
--primary-light: #005B9F;
--secondary: #E8EDF2;
--tertiary: #F5F7FA;
--neutral: #6B7280;
--bg-body: #F5F7FA;
--bg-card: #FFFFFF;
--bg-hover: #F0F4F8;
--bg-elevated: #FFFFFF;
--text: #1A1A2E;
--text-muted: #6B7280;
--border: #E5E7EB;
--border-active: #0066CC;
--border-w: 1px;
--border-w-thick: 1px;
--danger: #FF453A;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
--shadow-gold: 0 2px 12px rgba(212, 175, 55, 0.12);
--glow-gold: 0 0 8px rgba(212, 175, 55, 0.2);
--gradient-gold: linear-gradient(
180deg,
#FFF4C8 0%,
#F0D875 18%,
#D4AF37 50%,
#B8942B 78%,
#8B6914 100%
);
--gradient-gold-border: linear-gradient(
180deg,
#FFF8DC 0%,
#E8C96A 35%,
#A8841F 70%,
#5C4A12 100%
);
--gradient-card: linear-gradient(160deg, #1E1A12 0%, #141414 40%, #0A0A0A 100%);
--player-header-h: 52px;
--danger: #DC2626;
--success: #059669;
--warning: #F8971F;
--radius: 8px;
--radius-sm: 6px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--shadow-gold: 0 2px 12px rgba(0, 61, 107, 0.12);
--glow-gold: 0 0 8px rgba(0, 61, 107, 0.12);
--gradient-gold: linear-gradient(180deg, #F0F4F8 0%, #FFFFFF 100%);
--gradient-gold-border: linear-gradient(180deg, #E5E7EB 0%, #FFFFFF 100%);
--gradient-card: linear-gradient(160deg, #FFFFFF 0%, #F5F7FA 100%);
--space-card: 16px;
--space-section: 12px;
--surface-accent: rgba(0, 102, 204, 0.06);
--border-gold-soft: rgba(0, 102, 204, 0.2);
--player-header-h: 48px;
--player-bottom-nav-h: 54px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
/** 金色描边按钮(避免大面积实心填充) */
.btn-gold-outline {
background: #141414;
border: 1px solid var(--border-gold-soft);
color: var(--primary-light);
font-weight: 800;
}
.btn-gold-outline:active:not(:disabled) {
background: rgba(212, 175, 55, 0.1);
border-color: var(--border-gold);
/* Gold kept only for odds/betting accent as orange-amber */
:root {
--odds-accent: #F8971F;
--odds-accent-light: #FBBF24;
--odds-accent-soft: rgba(248, 151, 31, 0.1);
--captcha-canvas-bg: #E8EDF2;
--captcha-char: #003D6B;
--captcha-noise-rgb: 0, 61, 107;
}
/** 选中态:浅底 + 金边,非整块金色 */
.tab-gold-active {
background: rgba(212, 175, 55, 0.08) !important;
border-color: var(--border-gold) !important;
color: var(--primary-light) !important;
}
/* 登录等关键区域:轻量金边(非厚重 PS 描边) */
.ps-gold-frame {
position: relative;
border: 1px solid var(--border-gold-soft) !important;
border-radius: var(--radius);
background: rgba(14, 14, 14, 0.92) !important;
box-shadow: var(--shadow);
}
.ps-gold-frame::before,
.ps-gold-frame::after {
display: none;
}
.ps-gold-input {
background: #0d0d0d !important;
border: 1px solid var(--border) !important;
box-shadow: none;
color: var(--text);
}
.ps-gold-input::placeholder {
color: rgba(255, 255, 255, 0.35);
}
.ps-gold-input:focus {
border-color: var(--border-gold-soft) !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.12) !important;
}
/* 主按钮:斜面浮雕 + 渐变描边 + 外发光 */
.btn-primary {
position: relative;
isolation: isolate;
padding: 17px 24px;
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: #3D2800;
font-weight: 900;
font-size: 16px;
width: 100%;
letter-spacing: 0.1em;
text-transform: uppercase;
text-shadow:
0 1px 0 rgba(255, 252, 235, 0.95),
0 -1px 0 rgba(120, 85, 15, 0.35);
transition: transform 0.12s ease;
z-index: 0;
}
.btn-primary::before {
content: '';
position: absolute;
inset: -3px;
border-radius: calc(var(--radius-sm) + 3px);
background: linear-gradient(
180deg,
#FFFCE8 0%,
#F7E08A 12%,
#D4AF37 35%,
#8B6914 55%,
#5C4A12 72%,
#E8C040 90%,
#FFF8DC 100%
);
z-index: -2;
box-shadow:
0 0 14px rgba(255, 210, 70, 0.55),
0 0 28px rgba(212, 175, 55, 0.28),
0 6px 14px rgba(0, 0, 0, 0.55);
}
.btn-primary::after {
content: '';
position: absolute;
inset: 2px;
border-radius: calc(var(--radius-sm) - 1px);
background: linear-gradient(
180deg,
#FFFBE8 0%,
#FFE566 6%,
#F0D050 22%,
#D4AF37 48%,
#B8860B 72%,
#8B6914 100%
);
z-index: -1;
box-shadow:
inset 0 2px 0 rgba(255, 255, 255, 0.95),
inset 0 4px 10px rgba(255, 240, 180, 0.45),
inset 0 -2px 0 rgba(90, 65, 12, 0.85),
inset 0 -6px 14px rgba(45, 32, 6, 0.5);
}
.btn-primary:active:not(:disabled) {
transform: translateY(2px) scale(0.985);
}
.btn-primary:active:not(:disabled)::after {
background: linear-gradient(
180deg,
#E8C040 0%,
#C9962A 40%,
#8B6914 100%
);
box-shadow:
inset 0 4px 10px rgba(35, 24, 4, 0.65),
inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
.btn-primary:active:not(:disabled)::before {
box-shadow:
0 0 10px rgba(255, 200, 50, 0.35),
0 2px 6px rgba(0, 0, 0, 0.5);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
filter: saturate(0.4);
}
html,
body,
#app {
height: 100%;
min-height: 100%;
touch-action: manipulation;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
height: -webkit-fill-available;
}
/* ── Global resets ── */
html, body, #app { height: 100%; }
body {
min-height: 100%;
min-height: -webkit-fill-available;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* 保留 bg 位图;用 scroll 替代 fixed减轻移动端滚动重绘后续可换 WebP/AVIF */
background-color: var(--tertiary);
background-image:
radial-gradient(ellipse 120% 60% at 50% -10%, rgba(212, 175, 55, 0.14), transparent 55%),
linear-gradient(rgba(0, 0, 0, 0.88), rgba(0, 0, 0, 0.88)),
url('./assets/images/bg.webp');
background-repeat: no-repeat;
background-position: center center;
background-size: cover;
background-attachment: scroll;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: var(--bg-body);
color: var(--text);
overflow: hidden;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (min-width: 1024px) {
body {
background-image: url('./assets/images/pcbg.webp');
background-attachment: fixed;
background-color: var(--bg-body);
}
}
@@ -234,29 +75,25 @@ body {
}
a { color: inherit; text-decoration: none; }
button { cursor: pointer; border: none; font-family: inherit; }
button {
cursor: pointer;
border: none;
font-family: inherit;
}
/* ── Input ── */
input {
font-family: inherit;
background: #0D0D0D;
background: #FFFFFF;
border: 1px solid var(--border);
color: var(--text);
padding: 14px 16px;
padding: 12px 14px;
border-radius: var(--radius-sm);
width: 100%;
font-size: 16px;
font-weight: 500;
transition: border-color 0.2s, box-shadow 0.2s;
}
input:focus {
outline: none;
border-color: var(--border-gold-soft);
border-color: var(--border-active);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1);
}
input:-webkit-autofill,
@@ -265,13 +102,84 @@ input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-text-fill-color: var(--text);
caret-color: var(--text);
box-shadow: 0 0 0 1000px #0a0a0a inset;
box-shadow: 0 0 0 1000px #F0F4F8 inset;
border: 1px solid var(--border);
transition: background-color 99999s ease-out 0s;
}
/* ── Primary button (blue 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(--primary);
color: #FFFFFF;
font-weight: 700;
font-size: 15px;
letter-spacing: 0.02em;
width: 100%;
transition: background 0.15s, transform 0.1s;
box-shadow: 0 2px 8px rgba(0, 61, 107, 0.2);
}
.btn-primary:hover { background: var(--primary-light); }
.btn-primary:active:not(:disabled) {
background: var(--primary-dark);
transform: scale(0.985);
}
.btn-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* ── Gold-outline (now blue outline) ── */
.btn-gold-outline {
background: #FFFFFF;
border: 1px solid var(--border-active);
color: var(--primary);
font-weight: 700;
}
.btn-gold-outline:active:not(:disabled) {
background: rgba(0, 102, 204, 0.06);
border-color: var(--primary);
}
/* ── Active tab: light blue ── */
.tab-gold-active {
background: rgba(0, 102, 204, 0.06) !important;
border-color: var(--border-active) !important;
color: var(--primary) !important;
}
/* ── Gold frame (now light blue frame) ── */
.ps-gold-frame {
position: relative;
border: 1px solid var(--border) !important;
border-radius: var(--radius);
background: #FFFFFF !important;
box-shadow: var(--shadow);
}
.ps-gold-frame::before,
.ps-gold-frame::after { display: none; }
.ps-gold-input {
background: #F9FAFB !important;
border: 1px solid var(--border) !important;
box-shadow: none;
color: var(--text);
}
.ps-gold-input::placeholder { color: rgba(0, 0, 0, 0.3); }
.ps-gold-input:focus {
border-color: var(--border-active) !important;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.1) !important;
}
/* ── Odds button ── */
.odds-btn {
background: #161616;
background: #FFFFFF;
border: 1px solid var(--border);
color: var(--text);
padding: 12px 14px;
@@ -280,25 +188,14 @@ input:-webkit-autofill:active {
min-width: 78px;
transition: border-color 0.2s, box-shadow 0.2s, transform 0.15s;
}
.odds-btn:active { transform: scale(0.96); }
.odds-btn.selected {
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.12);
border: 1px solid var(--border-active);
background: rgba(0, 102, 204, 0.06);
box-shadow: none;
}
.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;
@@ -307,15 +204,14 @@ input:-webkit-autofill:active {
letter-spacing: 0.04em;
text-transform: uppercase;
}
.odds-btn .value {
font-size: 18px;
font-weight: 800;
color: var(--primary-light);
text-shadow: 0 0 8px rgba(212, 175, 55, 0.5);
color: var(--primary);
margin-top: 2px;
}
/* ── Card ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
@@ -323,20 +219,20 @@ input:-webkit-autofill:active {
padding: 16px;
margin-bottom: 12px;
box-shadow: var(--shadow);
background: rgba(26, 26, 26, 0.92);
}
/* ── Section title ── */
.section-title {
font-size: 18px;
font-weight: 800;
margin-bottom: 14px;
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
padding: 4px 0 4px 12px;
border-left: 3px solid var(--border-gold);
color: var(--primary-light);
letter-spacing: 0.06em;
text-transform: uppercase;
border-left: 3px solid var(--primary);
color: var(--text);
letter-spacing: 0.02em;
}
/* ── Empty / loading state ── */
.state {
display: flex;
flex-direction: column;
@@ -348,4 +244,4 @@ input:-webkit-autofill:active {
color: var(--text-muted);
font-weight: 600;
font-size: 14px;
}
}

View File

@@ -1,4 +1,99 @@
/* Hover Cursors and active resets */
/* ════════════════════════════════════════════════════════════
Desktop Interaction System — Theme-2 Blue-White
════════════════════════════════════════════════════════════ */
/* ── Global Font System ── */
/* Heading font: Russo One — titles, labels, headings, status badges, nav, section names */
.desktop-shell h1,
.desktop-shell h2,
.desktop-shell h3,
.desktop-shell h4,
.desktop-shell h5,
.desktop-shell h6,
.desktop-shell .page-title,
.desktop-shell [class*="-title"]:not(input),
.desktop-shell [class*="-header"]:not(input),
.desktop-shell [class*="-label"]:not(input),
.desktop-shell [class*="-lbl"]:not(input),
.desktop-shell [class*="-badge"]:not(input),
.desktop-shell [class*="-meta"]:not(input),
.desktop-shell [class*="-name"]:not(input),
.desktop-shell [class*="-status"]:not(input),
.desktop-shell [class*="-pick"]:not(input),
.desktop-shell [class*="-section"]:not(input),
.desktop-shell [class*="-heading"]:not(input),
.desktop-shell [class*="-nav"]:not(input),
.desktop-shell [class*="-tab"]:not(input),
.desktop-shell [class*="-tag"]:not(input),
.desktop-shell [class*="-brand"]:not(input),
.desktop-shell [class*="-league"]:not(input),
.desktop-shell [class*="-action"]:not(input),
.desktop-shell [class*="-chip"]:not(input),
.desktop-shell [class*="-scope"]:not(input),
.desktop-shell .site-name,
.desktop-shell .market-title,
.desktop-shell .market-hdr,
.desktop-shell .market-filter-label,
.desktop-shell .market-filter-chip,
.desktop-shell .score-section-title,
.desktop-shell .panel-head,
.desktop-shell .versus-row,
.desktop-shell .kickoff-time,
.desktop-shell .locked-lbl,
.desktop-shell .lbl,
.desktop-shell .label,
.desktop-shell .section-hdr,
.desktop-shell .leagues-hdr,
.desktop-shell .time-btn,
.desktop-shell .hero-sub,
.desktop-shell .lang-opt,
.desktop-shell .hero-caption,
.desktop-shell .title,
.desktop-shell .saishi,
.desktop-shell .nav-link,
.desktop-shell .nav-item,
.desktop-shell .main-tab,
.desktop-shell .name,
.desktop-shell .match-name,
.desktop-shell .selection-name,
.desktop-shell .league-name,
.desktop-shell .league-title,
.desktop-shell .hub-nav-title,
.desktop-shell .desktop-records-status,
.desktop-shell .desktop-records-filter-group-label,
.desktop-shell .desktop-records-table th,
.desktop-shell .data-table th,
.desktop-shell .desktop-wallet-subnav-link,
.desktop-shell .desktop-records-action-btn {
font-family: var(--font-heading);
}
/* Data font: Chakra Petch / SF Mono — odds, amounts, balances, stakes, returns, numbers */
.desktop-shell [class*="odds"]:not(input),
.desktop-shell [class*="amount"]:not(input),
.desktop-shell [class*="balance"]:not(input),
.desktop-shell [class*="stat-"]:not(input),
.desktop-shell [class*="-val"]:not(input),
.desktop-shell [class*="-value"]:not(input),
.desktop-shell [class*="stake"]:not(input),
.desktop-shell [class*="return"]:not(input),
.desktop-shell [class*="-num"]:not(input),
.desktop-shell [class*="-price"]:not(input),
.desktop-shell [class*="-payout"]:not(input),
.desktop-shell [class*="-win"]:not(input),
.desktop-shell .num-cell,
.desktop-shell .id-cell,
.desktop-shell .mono,
.desktop-shell .currency,
.desktop-shell .score,
.desktop-shell .val,
.desktop-shell .order-no,
.desktop-shell .bank-card-number,
.desktop-shell .hero-counter {
font-family: var(--font-mono);
}
/* Hover Cursors and active resets ── */
.desktop-shell a,
.desktop-shell button,
.desktop-shell select,
@@ -8,23 +103,54 @@
touch-action: auto;
}
/* Scrollbar customizations */
/* ── Global focus-visible (accessibility) ── */
.desktop-shell :focus-visible {
outline: 2px solid var(--border-active);
outline-offset: 2px;
border-radius: var(--radius-xs);
transition: outline-offset var(--transition-fast);
}
.desktop-shell input:focus-visible,
.desktop-shell textarea:focus-visible,
.desktop-shell select:focus-visible {
outline: none;
border-color: var(--border-active) !important;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.15) !important;
}
/* ── Scrollbar customizations ── */
.hide-scrollbar,
.no-scrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hide-scrollbar::-webkit-scrollbar,
.no-scrollbar::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.desktop-shell ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.desktop-shell ::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
.desktop-shell ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
background: rgba(0, 0, 0, 0.04);
border-radius: 3px;
}
.desktop-shell ::-webkit-scrollbar-thumb {
background: rgba(0, 61, 107, 0.18);
border-radius: 3px;
transition: background var(--transition);
}
.desktop-shell ::-webkit-scrollbar-thumb:hover {
background: var(--primary-light);
background: rgba(0, 61, 107, 0.35);
}
/* Hover highlights */
/* ── Hover highlights ── */
.desktop-shell .hover-bg:hover {
background-color: var(--bg-hover) !important;
}
@@ -35,62 +161,71 @@
}
.desktop-shell .hover-gold-text:hover {
color: var(--primary-light) !important;
color: var(--primary-lighter) !important;
}
/* Form elements styling */
/* ── Form elements styling ── */
.desktop-shell input {
padding: 10px 12px;
padding: var(--space-4) var(--space-5);
font-size: 13px;
border-radius: 4px;
border-radius: var(--radius-xs);
transition: border-color var(--transition), box-shadow var(--transition);
}
.desktop-shell input:focus {
border-color: var(--border-gold) !important;
box-shadow: 0 0 0 2px rgba(212, 175, 55, 0.2) !important;
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.15) !important;
}
/* Button hover overrides */
/* ── Button hover overrides ── */
.desktop-shell .btn-gold-outline {
transition: background-color 0.2s, border-color 0.2s;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.desktop-shell .btn-gold-outline:hover:not(:disabled) {
background: rgba(212, 175, 55, 0.08);
border-color: var(--border-gold);
background: rgba(0, 102, 204, 0.06);
border-color: var(--border-active);
box-shadow: var(--glow-gold);
}
.desktop-shell .btn-gold-outline:active {
transform: none; /* No mobile shrink on PC */
transform: scale(0.98); /* subtle press feedback */
}
.desktop-shell .btn-primary {
transition: filter 0.2s, transform 0.1s;
transition: filter var(--transition), transform var(--transition-fast), box-shadow var(--transition);
}
.desktop-shell .btn-primary:hover:not(:disabled) {
filter: brightness(1.1);
filter: brightness(1.08);
box-shadow: var(--glow-gold-lg);
}
.desktop-shell .btn-primary:active {
transform: translateY(1px);
transform: translateY(1px) scale(0.99);
}
/* Skeleton rows animation for high density PC loaders */
/* ── Skeleton loading animation ── */
@keyframes desktop-skeleton-glow {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.desktop-skeleton {
background: linear-gradient(90deg, #161616 25%, #222222 37%, #161616 63%);
background: linear-gradient(90deg,
#E5E7EB 25%,
#EFF3F8 37%,
#E5E7EB 63%
);
background-size: 400% 100%;
animation: desktop-skeleton-glow 1.4s ease infinite;
border-radius: var(--radius-sm);
}
/* ── Bet locate pulse ── */
@keyframes bet-locate-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(212, 175, 55, 0);
box-shadow: 0 0 0 0 rgba(0, 102, 204, 0);
}
50% {
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.45);
box-shadow: 0 0 0 3px rgba(0, 102, 204, 0.45);
}
}
@@ -98,5 +233,17 @@
animation: bet-locate-pulse 0.6s ease 3;
border-color: var(--border-gold) !important;
position: relative;
z-index: 1;
z-index: var(--z-base);
}
/* ── Reduced motion support ── */
@media (prefers-reduced-motion: reduce) {
.desktop-shell *,
.desktop-shell *::before,
.desktop-shell *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,40 +1,71 @@
/* Desktop Global layout structure */
/* Desktop Global layout structure — Theme-2 Blue-White */
.desktop-shell {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
background-color: #000;
background-image: url('../../assets/images/pcbg.webp');
background-size: cover;
background-position: center;
background-attachment: fixed;
background-color: var(--desktop-bg);
overflow: hidden;
}
/* Ambient gradient overlay for depth (light blue tint) */
.desktop-shell::before {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 800px 500px at 0% 0%, rgba(0, 91, 159, 0.04), transparent 60%),
radial-gradient(ellipse 600px 400px at 100% 100%, rgba(0, 102, 204, 0.03), transparent 60%);
pointer-events: none;
z-index: 0;
}
.desktop-header-wrap {
flex-shrink: 0;
height: var(--desktop-header-h);
background: #111111;
background: var(--glass-bg-strong);
backdrop-filter: var(--blur-md);
-webkit-backdrop-filter: var(--blur-md);
border-bottom: 1px solid var(--border);
z-index: 120;
z-index: var(--z-header);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
padding: 0 var(--space-7);
box-shadow: var(--shadow-xs);
}
.desktop-marquee-wrap {
flex-shrink: 0;
z-index: 110;
z-index: var(--z-sticky);
border-bottom: 1px solid var(--border);
background: #111;
background: var(--glass-bg);
backdrop-filter: var(--blur-sm);
-webkit-backdrop-filter: var(--blur-sm);
}
.desktop-shell.betslip-hidden {
--desktop-right-betslip-w: 0px;
}
.desktop-shell.betslip-collapsed {
--desktop-right-betslip-w: var(--desktop-right-betslip-collapsed-w);
}
.desktop-body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
position: relative;
z-index: var(--z-base);
}
.desktop-main-container {
flex: 1;
display: flex;
min-height: 0;
min-width: 0;
overflow: hidden;
position: relative;
}
@@ -51,14 +82,16 @@
.desktop-betting-sports-row {
flex-shrink: 0;
width: 100%;
padding: 0 16px;
padding: 0 var(--space-6);
border-bottom: 1px solid var(--border);
background: rgba(14, 14, 14, 0.85);
background: var(--glass-bg);
backdrop-filter: var(--blur-sm);
-webkit-backdrop-filter: var(--blur-sm);
}
.desktop-layout-betting {
display: grid;
grid-template-columns: var(--desktop-left-sidebar-w) 1fr var(--desktop-right-betslip-w);
grid-template-columns: var(--desktop-left-sidebar-w) 1fr;
flex: 1;
width: 100%;
min-height: 0;
@@ -66,13 +99,13 @@
.desktop-betting-left {
border-right: 1px solid var(--border);
background: rgba(14, 14, 14, 0.6);
background: var(--desktop-sidebar-bg);
overflow-y: auto;
}
.desktop-betting-center {
overflow-y: auto;
padding: 14px 16px;
padding: var(--space-6) var(--space-6);
min-width: 0;
}
@@ -80,12 +113,6 @@
padding: 0;
}
.desktop-betting-right {
border-left: 1px solid var(--border);
background: rgba(14, 14, 14, 0.85);
overflow-y: auto;
}
/* Account Layout: full-width records center */
.desktop-layout-account {
display: flex;
@@ -101,8 +128,10 @@
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
padding: 28px 40px 40px;
overflow-y: auto;
overflow-x: hidden;
padding: var(--space-9) var(--space-11) var(--space-12);
align-items: stretch;
}
.desktop-account-center > .desktop-records-page {
@@ -132,7 +161,7 @@
height: 100%;
overflow-y: auto;
align-items: center;
padding: 20px;
padding: var(--space-7);
}
.desktop-marketing-content {
@@ -143,23 +172,31 @@
/* Hub Layout: Left Sidebar (List) + Right Outlet (Detail) */
.desktop-layout-hub {
display: grid;
grid-template-columns: 320px 1fr;
grid-template-columns: 360px 1fr;
width: 100%;
height: 100%;
min-height: 0;
}
.desktop-hub-left {
border-right: 1px solid var(--border);
background: rgba(14, 14, 14, 0.6);
background: var(--desktop-sidebar-bg);
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
}
.desktop-hub-left > * {
flex: 1;
min-height: 0;
min-width: 0;
}
.desktop-hub-right {
overflow-y: auto;
padding: 20px;
background: rgba(10, 10, 10, 0.3);
padding: var(--space-8);
background: var(--desktop-bg);
}
/* Auth Layout: Centered container */
@@ -170,15 +207,17 @@
width: 100%;
height: 100%;
overflow-y: auto;
padding: 40px;
padding: var(--space-11);
}
.desktop-auth-card {
width: 100%;
max-width: 440px;
padding: 30px;
background: rgba(20, 20, 20, 0.95);
border: 1px solid var(--border-gold-soft);
border-radius: 8px;
box-shadow: var(--shadow);
padding: var(--space-9);
background: var(--glass-bg-strong);
backdrop-filter: var(--blur-lg);
-webkit-backdrop-filter: var(--blur-lg);
border: 1px solid var(--border-active);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
}

View File

@@ -1,11 +1,14 @@
/* PC account records: bets, wallet, recharge, cashbacks */
/* ════════════════════════════════════════════════════════════
PC Account Records — Theme-2 Blue-White
Bets, wallet, recharge, cashbacks
════════════════════════════════════════════════════════════ */
.desktop-records-page {
width: 100%;
max-width: 1480px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--space-6);
flex: 1;
min-height: 0;
height: 100%;
@@ -16,10 +19,10 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px 32px;
gap: var(--space-7) var(--space-11);
flex-wrap: wrap;
flex-shrink: 0;
padding-bottom: 14px;
padding-bottom: var(--space-5);
border-bottom: 1px solid var(--border);
}
@@ -34,7 +37,7 @@
.desktop-records-actions {
display: flex;
align-items: center;
gap: 10px;
gap: var(--space-4);
flex-shrink: 0;
}
@@ -42,27 +45,28 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 18px;
border-radius: 6px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
padding: var(--space-4) var(--space-7);
border-radius: var(--radius-sm);
border: 1px solid var(--border-active);
background: rgba(0, 102, 204, 0.08);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.desktop-records-action-btn:hover {
background: rgba(212, 175, 55, 0.14);
border-color: var(--border-gold);
background: rgba(0, 102, 204, 0.14);
border-color: var(--border-active);
box-shadow: var(--glow-gold);
}
.desktop-records-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: var(--space-2);
align-items: center;
}
@@ -70,14 +74,14 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
gap: var(--space-4) var(--space-6);
}
.desktop-records-filter-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
gap: var(--space-4);
}
.desktop-records-filter-group-label {
@@ -92,73 +96,76 @@
.desktop-records-filter-divider {
width: 1px;
height: 22px;
background: rgba(255, 255, 255, 0.12);
background: var(--border);
flex-shrink: 0;
}
.desktop-records-filter-chip {
padding: 7px 16px;
border-radius: 6px;
padding: 7px var(--space-6);
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
background: var(--bg-hover);
border: 1px solid transparent;
cursor: pointer;
transition: color 0.2s, border-color 0.2s, background 0.2s;
transition: color var(--transition), border-color var(--transition), background var(--transition);
white-space: nowrap;
}
.desktop-records-filter-chip:hover {
color: #fff;
border-color: rgba(212, 175, 55, 0.25);
color: var(--text);
border-color: rgba(0, 102, 204, 0.25);
}
.desktop-records-filter-chip.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: rgba(212, 175, 55, 0.35);
background: rgba(0, 102, 204, 0.1);
border-color: rgba(0, 102, 204, 0.35);
box-shadow: 0 0 0 1px rgba(0, 102, 204, 0.1);
}
.desktop-wallet-subnav {
display: flex;
flex-wrap: wrap;
gap: 4px;
gap: var(--space-1);
}
.desktop-wallet-subnav-link {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 6px;
padding: var(--space-4) var(--space-6);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 700;
color: var(--text-muted);
text-decoration: none;
border: 1px solid transparent;
transition: color 0.2s, background 0.2s, border-color 0.2s;
transition: color var(--transition), background var(--transition), border-color var(--transition);
}
.desktop-wallet-subnav-link:hover {
color: #fff;
background: rgba(255, 255, 255, 0.04);
color: var(--text);
background: var(--bg-hover);
}
.desktop-wallet-subnav-link.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: rgba(212, 175, 55, 0.28);
background: rgba(0, 102, 204, 0.1);
border-color: rgba(0, 102, 204, 0.28);
}
/* ── Records Panel ── */
.desktop-records-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
background: rgba(18, 18, 18, 0.94);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.desktop-records-table-wrap {
@@ -173,19 +180,23 @@
font-size: 13px;
}
/* ── Table Header: glassmorphism + heading font ── */
.desktop-records-table th {
position: sticky;
top: 0;
z-index: 2;
padding: 12px 16px;
z-index: var(--z-sticky);
padding: var(--space-5) var(--space-6);
text-align: left;
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
background: #121212;
font-family: var(--font-heading);
color: var(--primary);
background: var(--glass-bg-strong);
backdrop-filter: var(--blur-md);
-webkit-backdrop-filter: var(--blur-md);
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--border);
border-bottom: 2px solid var(--border-active);
white-space: nowrap;
}
@@ -194,28 +205,44 @@
}
.desktop-records-table td {
padding: 12px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--border);
color: var(--text);
vertical-align: middle;
}
.desktop-records-table tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.015);
background: var(--bg-hover);
}
/* ── Row hover: blue left-border indicator ── */
.desktop-records-table tbody tr.hover-row {
transition: background 0.15s;
transition: background var(--transition-fast), box-shadow var(--transition-fast);
}
.desktop-records-table tbody tr.hover-row:hover {
background: rgba(212, 175, 55, 0.06);
.desktop-records-table tbody tr.clickable-row {
cursor: pointer;
}
.desktop-records-table tbody tr.hover-row:hover,
.desktop-records-table tbody tr.clickable-row:hover {
background: rgba(0, 102, 204, 0.06);
box-shadow: inset 3px 0 0 var(--primary-lighter);
}
.desktop-records-table .id-cell {
color: var(--text);
font-weight: 700;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.03em;
}
.desktop-records-table .num-cell {
text-align: right;
font-variant-numeric: tabular-nums;
font-weight: 700;
font-family: var(--font-data);
}
.desktop-records-table .date-cell {
@@ -228,29 +255,33 @@
color: var(--primary-light);
font-weight: 700;
text-decoration: none;
transition: color var(--transition-fast);
}
.desktop-records-table .link-cell:hover {
color: var(--primary-lighter);
text-decoration: underline;
}
/* ── Pagination ── */
.desktop-records-pagination {
flex-shrink: 0;
padding: 12px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(10, 10, 10, 0.35);
padding: var(--space-5) var(--space-6);
border-top: 1px solid var(--border);
background: var(--desktop-bg);
}
/* ── Empty / Loading States ── */
.desktop-records-loading,
.desktop-records-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
gap: var(--space-5);
flex: 1;
min-height: 0;
padding: 48px 24px;
padding: var(--space-13) var(--space-9);
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
@@ -264,18 +295,18 @@
.desktop-records-table-empty {
text-align: center;
padding: 56px 16px !important;
padding: var(--space-13) var(--space-6) !important;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.desktop-records-table-empty .empty-hint {
margin: 8px 0 0;
margin: var(--space-4) 0 0;
font-size: 13px;
font-weight: 500;
line-height: 1.6;
color: var(--text-muted);
color: var(--text-light);
}
.desktop-records-loading-cell {
@@ -283,27 +314,76 @@
align-items: center;
justify-content: center;
min-height: 280px;
padding: 48px 24px;
padding: var(--space-13) var(--space-9);
}
/* ── Status Badges: dot indicator + pulse ── */
.desktop-records-status {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 999px;
gap: 6px;
padding: var(--space-2) var(--space-5);
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
white-space: nowrap;
}
.desktop-records-status.status-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
.desktop-records-status.status-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
.desktop-records-status.status-pending { background: rgba(212, 175, 55, 0.12); color: var(--primary-light); }
.desktop-records-status.status-push { background: rgba(100, 100, 100, 0.12); color: #aaa; }
.desktop-records-status.status-cancelled { background: rgba(100, 100, 100, 0.1); color: #777; }
.desktop-records-status.status-default { background: rgba(100, 100, 100, 0.1); color: #888; }
/* Dot indicator before status text */
.desktop-records-status::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.desktop-records-amount.pos { color: var(--primary-light); }
.desktop-records-amount.neg { color: var(--danger); }
.desktop-records-status.status-won {
background: rgba(52, 199, 89, 0.12);
color: #2BB058;
}
.desktop-records-status.status-won::before { background: #34C759; }
.desktop-records-status.status-lost {
background: rgba(255, 69, 58, 0.12);
color: #E53935;
}
.desktop-records-status.status-lost::before { background: #FF453A; }
.desktop-records-status.status-pending {
background: rgba(0, 102, 204, 0.12);
color: var(--primary-light);
}
.desktop-records-status.status-pending::before {
background: var(--primary-lighter);
animation: pulse-dot 1.6s ease-in-out infinite;
}
.desktop-records-status.status-push {
background: rgba(100, 100, 100, 0.12);
color: #888;
}
.desktop-records-status.status-push::before { background: #aaa; }
.desktop-records-status.status-cancelled {
background: rgba(100, 100, 100, 0.1);
color: #777;
}
.desktop-records-status.status-cancelled::before { background: #999; }
.desktop-records-status.status-default {
background: rgba(100, 100, 100, 0.1);
color: #888;
}
.desktop-records-status.status-default::before { background: #aaa; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.3); }
}
/* ── Amount styling ── */
.desktop-records-amount.pos { color: var(--primary-light); font-weight: 800; }
.desktop-records-amount.neg { color: var(--danger); font-weight: 800; }
.desktop-records-amount.zero { color: var(--text-muted); }

View File

@@ -1,38 +1,123 @@
.desktop-shell {
/* Color Palette overrides */
--primary: #D4AF37;
--primary-dark: #A8841F;
--primary-light: #F0D875;
--secondary: #141414;
--tertiary: #0A0A0A;
--neutral: #8E8E93;
--bg-body: #050505;
--bg-card: rgba(20, 20, 20, 0.95);
--bg-hover: rgba(38, 38, 38, 0.95);
--bg-elevated: rgba(45, 45, 45, 0.95);
--text: #FFFFFF;
--text-muted: #8E8E93;
--border: #262626;
--border-gold: #D4AF37;
--border-gold-soft: rgba(212, 175, 55, 0.25);
--border-w: 1px;
--border-w-thick: 1px;
--danger: #FF453A;
--radius: 4px;
--radius-sm: 2px;
--shadow: 0 4px 16px rgba(0, 0, 0, 0.65);
--shadow-gold: 0 2px 12px rgba(212, 175, 55, 0.15);
--glow-gold: 0 0 6px rgba(212, 175, 55, 0.15);
/* ════════════════════════════════════════════════════════════
THEME-2 · Pinnacle Blue-White Design System
════════════════════════════════════════════════════════════ */
/* PC Dimensioning */
/* ── Font System ── */
--font-heading: 'Russo One', 'Chakra Petch', 'Segoe UI', sans-serif;
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'SF Mono', 'Consolas', 'Monaco', monospace;
--font-data: 'Chakra Petch', 'SF Mono', 'Consolas', monospace;
/* ── Color Palette ── */
--primary: #003D6B;
--primary-dark: #002847;
--primary-light: #005B9F;
--primary-lighter: #0078D4;
--secondary: #E8EDF2;
--tertiary: #F5F7FA;
--neutral: #6B7280;
--bg-body: #F5F7FA;
--bg-card: #FFFFFF;
--bg-hover: #F0F4F8;
--bg-elevated: #FFFFFF;
--text: #1A1A2E;
--text-muted: #6B7280;
--text-light: #9CA3AF;
--border: #E5E7EB;
--border-active: #0066CC;
--border-soft: rgba(0, 102, 204, 0.12);
/* ── Blue Accent Tokens (mapped from gold) ── */
--border-gold: #0066CC;
--border-gold-soft: rgba(0, 102, 204, 0.2);
--shadow-gold: 0 2px 12px rgba(0, 61, 107, 0.1);
--shadow-gold-lg: 0 8px 32px rgba(0, 61, 107, 0.16), 0 2px 8px rgba(0, 102, 204, 0.06);
--glow-gold: 0 0 6px rgba(0, 102, 204, 0.12);
--glow-gold-lg: 0 0 16px rgba(0, 102, 204, 0.2), 0 0 4px rgba(0, 102, 204, 0.1);
--odds-accent: #F8971F;
--odds-glow: 0 0 10px rgba(248, 151, 31, 0.35);
/* ── Spacing Scale (8dp rhythm) ── */
--space-0: 0px;
--space-1: 2px;
--space-2: 4px;
--space-3: 6px;
--space-4: 8px;
--space-5: 12px;
--space-6: 16px;
--space-7: 20px;
--space-8: 24px;
--space-9: 28px;
--space-10: 32px;
--space-11: 40px;
--space-12: 48px;
--space-13: 56px;
--space-14: 64px;
/* ── Radius Scale ── */
--radius: 8px;
--radius-sm: 6px;
--radius-xs: 4px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 20px;
--radius-full: 999px;
/* ── Shadow Scale (light-mode tuned) ── */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.08), 0 2px 6px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 8px 28px rgba(0, 0, 0, 0.1), 0 4px 12px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 16px 48px rgba(0, 0, 0, 0.14), 0 6px 18px rgba(0, 0, 0, 0.06);
/* ── Blur / Glassmorphism (light-mode tuned) ── */
--blur-sm: blur(6px);
--blur-md: blur(10px);
--blur-lg: blur(16px);
--glass-bg: rgba(255, 255, 255, 0.88);
--glass-bg-strong: rgba(255, 255, 255, 0.95);
--glass-border: rgba(255, 255, 255, 0.6);
/* ── Transition Tokens ── */
--transition-fast: 0.12s cubic-bezier(0.4, 0, 0.2, 1);
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
--transition-bounce: 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
--transition-spring: 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
/* ── Z-Index Scale ── */
--z-base: 1;
--z-sticky: 10;
--z-dropdown: 100;
--z-header: 120;
--z-modal: 1000;
--z-toast: 2000;
/* ── Desktop Layout Dimensions ── */
--desktop-header-h: 56px;
--desktop-left-sidebar-w: 220px;
--desktop-right-betslip-w: 288px;
/* Reset typography inside desktop */
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--desktop-right-betslip-expanded-w: 288px;
--desktop-right-betslip-collapsed-w: 40px;
/* ── Desktop Background ── */
--desktop-bg: #F5F7FA;
--desktop-border: #E5E7EB;
--desktop-sidebar-bg: #FFFFFF;
/* ── Borders ── */
--border-w: 1px;
--border-w-thick: 1px;
--danger: #DC2626;
--success: #34C759;
/* ── Typography ── */
font-family: var(--font-body);
font-size: 13px;
line-height: 1.4;
line-height: 1.5;
color: var(--text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

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,10 +1,24 @@
<script setup lang="ts">
import { watch } 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();
watch(
() => [isDesktop.value, route.params.betNo] as const,
([desktop, betNo]) => {
if (desktop && betNo) {
router.replace({ path: '/bets', query: { betNo: String(betNo) } });
}
},
{ immediate: true },
);
</script>
<template>
<DesktopBetDetailView v-if="isDesktop" />
<MobileBetDetailView v-else />
<MobileBetDetailView v-if="!isDesktop" />
</template>

View File

@@ -7,7 +7,10 @@ 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 GoldSpinner from '../components/GoldSpinner.vue';
import loginBg from '../assets/images/zt2.webp';
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
const { t, locale } = useI18n();
const router = useRouter();
@@ -98,11 +101,16 @@ function goLogin() {
</script>
<template>
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
<div class="login-lang">
<div class="login-page" :class="{ 'login-page--desktop': props.desktop }" :style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }">
<div v-if="!props.desktop" class="login-lang">
<LocaleSwitcher compact />
</div>
<form @submit.prevent="submit" class="login-form ps-gold-frame">
<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.resetting_password') }}</p>
</div>
<h2 class="form-title">{{ t('auth.forgot_password_title') }}</h2>
<div class="field">
@@ -111,10 +119,11 @@ function goLogin() {
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
class="field-input ps-gold-input"
type="tel"
required
autocomplete="tel-national"
:disabled="loading"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
@@ -125,16 +134,17 @@ function goLogin() {
<div class="inline-row">
<input
v-model="smsCode"
class="field-input"
class="field-input ps-gold-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()"
:disabled="sending || countdown > 0 || !phone.trim() || loading"
@click="sendCode"
>
{{ countdown > 0 ? `${countdown}s` : t('auth.send_sms') }}
@@ -146,11 +156,12 @@ function goLogin() {
<label>{{ t('auth.password') }}</label>
<input
v-model="password"
class="field-input"
class="field-input ps-gold-input"
type="password"
required
autocomplete="new-password"
minlength="8"
:disabled="loading"
:placeholder="t('auth.password_placeholder')"
/>
</div>
@@ -159,23 +170,35 @@ function goLogin() {
<label>{{ t('auth.confirm_password') }}</label>
<input
v-model="confirmPassword"
class="field-input"
class="field-input ps-gold-input"
type="password"
required
autocomplete="new-password"
minlength="8"
:disabled="loading"
/>
</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
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.resetting_password') }}
</span>
<span v-else>{{ t('auth.reset_password_btn') }}</span>
</button>
<button type="button" class="btn-skip" @click="goLogin">
<button type="button" class="btn-skip" :disabled="loading" @click="goLogin">
{{ t('auth.back_to_login') }}
</button>
</form>
</form>
</div>
</div>
</template>
@@ -184,7 +207,7 @@ function goLogin() {
position: absolute;
top: max(12px, env(safe-area-inset-top));
right: 16px;
z-index: 2;
z-index: 10;
}
.login-page {
@@ -197,29 +220,144 @@ function goLogin() {
align-items: center;
justify-content: flex-end;
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
background-color: var(--tertiary);
background-color: var(--bg-body);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
}
.login-form {
.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-stack {
max-width: none;
width: 100%;
}
.login-stack {
position: relative;
z-index: 1;
width: 100%;
max-width: 320px;
}
.login-form {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
padding: 14px;
}
.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(255, 255, 255, 0.88);
backdrop-filter: blur(4px);
pointer-events: all;
}
.form-busy-text {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--primary);
letter-spacing: 0.04em;
}
.btn-inline-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.login-page--desktop .login-form {
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.form-title {
margin: 0 0 2px;
font-size: 16px;
font-weight: 800;
color: #fff;
color: var(--text);
text-align: center;
}
.login-page--desktop .form-title {
margin: 0 0 8px;
font-size: 18px;
text-align: left;
color: var(--primary);
}
.login-page--desktop .field {
gap: 8px;
}
.login-page--desktop label {
font-size: 13px;
}
.login-page--desktop .field-input,
.login-page--desktop .btn-secondary {
height: 46px;
}
.login-page--desktop .btn-login {
margin-top: 10px;
min-height: 50px;
font-size: 15px;
}
.login-page--desktop .btn-skip {
margin-top: 6px;
padding: 12px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
}
.login-page--desktop .btn-skip:hover:not(:disabled) {
border-color: var(--border-active);
background: var(--surface-accent);
}
.login-page--desktop .error {
padding: 6px 10px;
border-radius: var(--radius-sm);
background: rgba(220, 38, 38, 0.08);
border-left: 2px solid var(--danger);
}
.btn-login.is-loading:disabled {
opacity: 1;
}
.field {
display: flex;
flex-direction: column;
@@ -239,19 +377,19 @@ label {
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #0d0d0d;
background: var(--bg-input);
color: var(--text);
font-size: 14px;
font-family: inherit;
}
.field-input::placeholder {
color: rgba(255, 255, 255, 0.32);
color: rgba(0, 0, 0, 0.35);
}
.field-input:focus {
outline: none;
border-color: #555;
border-color: var(--border-gold-soft);
}
.inline-row {
@@ -271,7 +409,7 @@ label {
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #141414;
background: var(--bg-card);
color: var(--text);
font-size: 11px;
font-weight: 600;
@@ -279,6 +417,12 @@ label {
cursor: pointer;
}
.btn-secondary:not(:disabled):active {
border-color: var(--primary);
background: var(--surface-accent);
color: var(--text);
}
.btn-secondary:disabled {
opacity: 0.45;
cursor: not-allowed;
@@ -302,14 +446,14 @@ label {
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;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
color: var(--primary-dark);
}
.error {

View File

@@ -8,7 +8,10 @@ import { useAppLocale } from '../composables/useAppLocale';
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
import PhoneCountrySelect from '../components/PhoneCountrySelect.vue';
import RobotVerify from '../components/RobotVerify.vue';
import loginBg from '../assets/images/h5bg.webp';
import GoldSpinner from '../components/GoldSpinner.vue';
import loginBg from '../assets/images/zt2.webp';
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
type LoginMode = 'account' | 'phone';
@@ -85,19 +88,51 @@ function goRegister() {
</script>
<template>
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
<div class="login-lang">
<div class="login-page" :class="{ 'login-page--desktop': props.desktop }" :style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }">
<div v-if="!props.desktop" class="login-lang">
<LocaleSwitcher compact />
</div>
<form @submit.prevent="submit" class="login-form ps-gold-frame">
<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">
<button
type="button"
class="auth-tab"
:class="{ active: loginMode === 'account' }"
:disabled="loading"
@click="switchLoginMode('account')"
>
{{ t('auth.login_by_account') }}
</button>
<button
type="button"
class="auth-tab"
:class="{ active: loginMode === 'phone' }"
:disabled="loading"
@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="field-input ps-gold-input"
required
autocomplete="username"
maxlength="32"
:disabled="loading"
:placeholder="t('auth.username_placeholder')"
/>
</div>
@@ -108,10 +143,11 @@ function goRegister() {
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
class="field-input ps-gold-input"
type="tel"
required
autocomplete="tel-national"
:disabled="loading"
:placeholder="t('auth.phone_local_placeholder')"
/>
</div>
@@ -119,30 +155,42 @@ function goRegister() {
<div class="field">
<label>{{ t('auth.password') }}</label>
<input v-model="password" class="field-input" type="password" required autocomplete="current-password" />
<input v-model="password" class="field-input ps-gold-input" type="password" required autocomplete="current-password" :disabled="loading" />
</div>
<button type="button" class="btn-forgot" @click="goForgotPassword">
<button type="button" class="btn-forgot" :disabled="loading" @click="goForgotPassword">
{{ t('auth.forgot_password') }}
</button>
<button type="button" class="btn-mode-switch" @click="switchLoginMode(loginMode === 'account' ? 'phone' : 'account')">
{{ loginMode === 'account' ? t('auth.login_by_phone') : t('auth.login_by_account') }}
</button>
<RobotVerify ref="captchaRef" />
<p v-if="resetSuccess" class="success">{{ t('auth.reset_success') }}</p>
<p v-if="error" class="error">{{ error }}</p>
<button type="submit" class="btn-login btn-gold-outline" :disabled="loading">
{{ t('auth.login') }}
<button
type="submit"
class="btn-login btn-gold-outline"
:class="{ 'is-loading': loading }"
:disabled="loading"
:aria-busy="loading"
>
<span v-if="loading" class="btn-inline-loading">
<GoldSpinner :size="18" :active="true" />
{{ t('auth.logging_in') }}
</span>
<span v-else>{{ t('auth.login') }}</span>
</button>
<button type="button" class="btn-skip" @click="goRegister">
<button v-if="props.desktop" type="button" class="btn-skip" :disabled="loading" @click="goRegister">
{{ t('auth.go_register') }}
</button>
</form>
<button type="button" class="btn-skip-light" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
</form>
<div v-if="!props.desktop" class="login-actions">
<button type="button" class="btn-skip" :disabled="loading" @click="goRegister">
{{ t('auth.go_register') }}
</button>
<button type="button" class="btn-skip-light" :disabled="loading" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</button>
</div>
</div>
</div>
</template>
@@ -151,7 +199,7 @@ function goRegister() {
position: absolute;
top: max(12px, env(safe-area-inset-top));
right: 16px;
z-index: 2;
z-index: 10;
}
.login-page {
@@ -163,20 +211,196 @@ function goRegister() {
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 34vh 20px calc(15vh + max(28px, env(safe-area-inset-bottom)));
background-color: var(--tertiary);
padding: 34vh 20px max(20px, env(safe-area-inset-bottom));
background-color: var(--bg-body);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
}
.login-form {
.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-stack {
max-width: none;
width: 100%;
}
.login-stack {
position: relative;
z-index: 1;
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.login-form {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.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(255, 255, 255, 0.88);
backdrop-filter: blur(4px);
pointer-events: all;
}
.form-busy-text {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--primary);
letter-spacing: 0.04em;
}
.btn-inline-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.login-page--desktop .login-form {
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.form-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 800;
color: var(--primary);
text-align: left;
letter-spacing: 0.02em;
}
.login-page--desktop .field {
gap: 8px;
}
.login-page--desktop label {
font-size: 13px;
}
.login-page--desktop .field-input {
height: 46px;
padding: 0 14px;
}
.login-page--desktop .auth-tabs {
margin-bottom: 8px;
}
.login-page--desktop .btn-login {
margin-top: 10px;
min-height: 50px;
font-size: 15px;
}
.login-page--desktop .btn-skip {
margin-top: 6px;
padding: 12px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
}
.login-page--desktop .btn-skip:hover:not(:disabled) {
border-color: var(--border-active);
background: var(--surface-accent);
}
.login-page--desktop .error {
padding: 6px 10px;
border-radius: var(--radius-sm);
background: rgba(220, 38, 38, 0.08);
border-left: 2px solid var(--danger);
}
.login-page--desktop .success {
padding: 6px 10px;
border-radius: var(--radius-sm);
background: rgba(5, 150, 105, 0.08);
border-left: 2px solid var(--success);
}
.btn-login.is-loading:disabled {
opacity: 1;
}
.auth-tab:disabled,
.btn-forgot:disabled,
.btn-skip:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.login-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0 14px 4px;
}
.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-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(--primary);
color: var(--text);
background: var(--surface-accent);
}
.field {
@@ -191,7 +415,7 @@ function goRegister() {
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #0d0d0d;
background: var(--bg-input);
color: var(--text);
font-size: 14px;
font-family: inherit;
@@ -199,7 +423,7 @@ function goRegister() {
.field-input:focus {
outline: none;
border-color: #555;
border-color: var(--border-gold-soft);
}
.inline-row {
@@ -220,36 +444,20 @@ label {
letter-spacing: 0.04em;
}
.btn-mode-switch {
align-self: flex-start;
margin: -2px 0 0;
padding: 0;
border: none;
background: transparent;
color: rgba(240, 216, 117, 0.75);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn-forgot {
align-self: flex-end;
margin: -4px 0 0;
padding: 0;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.5);
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn-forgot:active {
color: rgba(255, 255, 255, 0.75);
}
.btn-mode-switch:active {
color: rgba(240, 216, 117, 0.95);
color: var(--text);
}
.btn-login {
@@ -267,37 +475,34 @@ label {
}
.btn-skip {
margin-top: 2px;
padding: 8px 14px;
padding: 6px 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;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
color: var(--primary-dark);
}
.btn-skip-light {
position: absolute;
bottom: calc(20px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
padding: 6px 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(--text);
}
.error {
@@ -308,7 +513,7 @@ label {
.success {
margin: 0;
color: #6ee7a0;
color: var(--success);
font-size: 12px;
font-weight: 600;
line-height: 1.4;

View File

@@ -1,7 +1,5 @@
<script setup lang="ts">
import { computed, onActivated, watch } from 'vue';
const { embedded = false, desktop = false } = defineProps<{ embedded?: boolean; desktop?: boolean }>();
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
@@ -15,24 +13,25 @@ const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { hubContentItems, loading, load } = usePlayerHome();
const { embedded = false } = defineProps<{ embedded?: boolean }>();
const { announcementItems, bannerItems, loading, load } = usePlayerHome();
const announcementId = computed(() => String(route.params.id ?? ''));
const item = computed<PlayerContentItem | null>(() => {
const id = announcementId.value;
return hubContentItems.value.find((entry) => entry.id === id) ?? null;
return (
bannerItems.value.find((entry) => entry.id === id) ??
announcementItems.value.find((entry) => entry.id === id) ??
null
);
});
const isBanner = computed(() => item.value?.contentType === 'BANNER');
function goBack() {
if (embedded) return;
if (window.history.length > 1) {
router.back();
return;
}
router.push('/announcements');
router.back();
}
function goList() {
@@ -108,21 +107,13 @@ watch(announcementId, () => {
</script>
<template>
<div class="announce-detail" :class="{ 'announce-detail--embedded': embedded, 'announce-detail--desktop': desktop }">
<header v-if="embedded" class="page-header page-header--embedded-detail">
<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-if="desktop" class="page-header page-header--desktop">
<button type="button" class="back-btn" @click="goBack">
<span class="back-icon" aria-hidden="true"></span>
{{ t('announcements.back') }}
</button>
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<header v-else class="page-header">
<button type="button" class="back-btn back-btn--icon" :aria-label="t('announcements.back')" @click="goBack"></button>
<header v-else class="page-header page-header--hub">
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
@@ -132,7 +123,7 @@ watch(announcementId, () => {
<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,16 +167,6 @@ watch(announcementId, () => {
padding: 0 0 24px;
}
.announce-detail--embedded {
padding: 0;
min-height: 0;
}
.announce-detail--desktop {
padding: 0;
min-height: 0;
}
.page-header {
display: flex;
align-items: center;
@@ -195,68 +176,23 @@ watch(announcementId, () => {
border-bottom: 1px solid var(--border);
}
.page-header--desktop {
padding: 0 0 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.page-header--embedded-detail {
padding: 0 0 16px;
margin-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.page-header--embedded-detail h1 {
font-size: 16px;
color: var(--primary-light);
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #141414;
color: var(--gold);
font-size: 13px;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.back-btn:hover {
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
}
.back-btn--icon {
width: 36px;
height: 36px;
padding: 0;
justify-content: center;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--primary);
font-size: 22px;
}
.back-icon {
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.page-header--desktop h1 {
font-size: 16px;
color: var(--primary-light);
color: var(--primary-dark, #002847);
}
.state,
@@ -270,13 +206,14 @@ watch(announcementId, () => {
}
.link-btn {
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.detail-article {
@@ -284,32 +221,12 @@ watch(announcementId, () => {
padding: 0;
}
.announce-detail--embedded .detail-article,
.announce-detail--desktop .detail-article {
background: rgba(18, 18, 18, 0.92);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px 24px 24px;
}
.detail-hero {
margin: 16px -16px 0;
padding: 0;
background: #080808;
background: var(--bg-body);
line-height: 0;
}
.announce-detail--embedded .detail-hero,
.announce-detail--desktop .detail-hero {
margin: 0 0 18px;
max-height: min(280px, 36vh);
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.06);
background: #0a0a0a;
border-radius: 0;
}
.detail-hero img {
@@ -320,22 +237,10 @@ watch(announcementId, () => {
object-position: center;
}
.announce-detail--embedded .detail-hero img,
.announce-detail--desktop .detail-hero img {
max-height: min(280px, 36vh);
width: auto;
max-width: 100%;
}
.detail-body-wrap {
padding: 18px 0 0;
}
.announce-detail--embedded .detail-body-wrap,
.announce-detail--desktop .detail-body-wrap {
padding: 0;
}
.detail-date {
margin: 0 0 10px;
font-size: 12px;
@@ -347,20 +252,14 @@ watch(announcementId, () => {
font-size: 20px;
font-weight: 700;
line-height: 1.45;
color: #fff;
}
.announce-detail--embedded .detail-title,
.announce-detail--desktop .detail-title {
font-size: 18px;
margin-bottom: 10px;
color: var(--primary-dark, #002847);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #d0d0d0;
color: #374151;
}
.detail-body :deep(p) {
@@ -379,16 +278,6 @@ watch(announcementId, () => {
border-radius: 8px;
}
.announce-detail--embedded .detail-body :deep(img),
.announce-detail--desktop .detail-body :deep(img) {
max-height: min(320px, 40vh);
width: auto;
max-width: 100%;
margin-left: auto;
margin-right: auto;
object-fit: contain;
}
.detail-body :deep(ul),
.detail-body :deep(ol) {
margin: 0 0 12px;
@@ -425,8 +314,8 @@ watch(announcementId, () => {
padding: 11px 14px;
border: none;
border-radius: 8px;
background: var(--gradient-gold);
color: #1a1000;
background: var(--primary);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
@@ -434,7 +323,31 @@ watch(announcementId, () => {
.link-action--outline {
background: transparent;
border: 1px solid var(--border-gold-soft);
color: var(--gold);
border: 1px solid var(--border-active);
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(--primary-dark, #002847);
}
.is-embedded .detail-title {
font-size: 22px;
}
.is-embedded .detail-body-wrap {
padding-top: 12px;
}
</style>

View File

@@ -1,41 +1,23 @@
<script setup lang="ts">
import { computed, onActivated } from 'vue';
const { embedded = false } = defineProps<{ embedded?: boolean }>();
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePlayerHome, type PlayerContentItem } from '../composables/usePlayerHome';
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 { hubContentItems, loading, load } = usePlayerHome();
const { announcementItems, loading, load } = usePlayerHome();
const items = computed(() => hubContentItems.value);
const activeId = computed(() => String(route.params.id ?? ''));
function leaveAnnouncementHub() {
const prev = (window.history.state as { back?: string } | null)?.back;
if (prev && !prev.startsWith('/announcements')) {
router.back();
return;
}
router.push('/');
}
const items = computed(() => announcementItems.value);
function goBack() {
if (embedded) {
leaveAnnouncementHub();
return;
}
if (window.history.length > 1) {
router.back();
return;
}
router.push('/');
router.back();
}
function openDetail(id: string) {
@@ -47,28 +29,29 @@ function formatDate(createdAt?: string) {
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
}
function itemTitle(item: PlayerContentItem) {
function itemTitle(item: (typeof items.value)[number]) {
const title = item.translation?.title?.trim();
if (title) return title;
return stripHtml(item.translation?.body ?? '') || t('home.announcement_badge');
}
function itemPreview(item: PlayerContentItem) {
function itemPreview(item: (typeof items.value)[number]) {
const title = item.translation?.title?.trim();
const bodyText = stripHtml(item.translation?.body ?? '');
if (bodyText && bodyText !== title) return bodyText;
return '';
}
function typeLabel(item: PlayerContentItem) {
switch (item.contentType) {
case 'BANNER':
return t('announcements.type_banner');
case 'TICKER':
return t('announcements.type_ticker');
default:
return t('announcements.type_notice');
function isActive(id: string) {
return embedded && String(route.params.id ?? '') === id;
}
function onBack() {
if (embedded) {
router.push('/');
return;
}
goBack();
}
onActivated(() => {
@@ -77,17 +60,17 @@ onActivated(() => {
</script>
<template>
<div class="announce-page" :class="{ 'announce-page--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.title') }}</h1>
</header>
<header v-else class="embedded-header">
<button type="button" class="back-btn" @click="goBack">
<span class="back-icon" aria-hidden="true"></span>
<span class="back-label">{{ t('announcements.back') }}</span>
<div class="announce-page" :class="{ 'is-embedded': embedded }">
<header class="page-header">
<button
type="button"
class="back-btn"
:aria-label="t('announcements.back')"
@click="onBack"
>
</button>
<span class="embedded-title">{{ t('announcements.title') }}</span>
<h1>{{ t('announcements.title') }}</h1>
</header>
<div v-if="loading && !items.length" class="state">
@@ -104,14 +87,11 @@ onActivated(() => {
:key="item.id"
type="button"
class="list-row"
:class="{ 'list-row--active': embedded && activeId === item.id }"
:class="{ 'is-active': isActive(item.id) }"
@click="openDetail(item.id)"
>
<span class="row-main">
<span class="row-top">
<span class="type-badge">{{ typeLabel(item) }}</span>
<span class="title">{{ itemTitle(item) }}</span>
</span>
<span class="title">{{ itemTitle(item) }}</span>
<span v-if="itemPreview(item)" class="preview">{{ itemPreview(item) }}</span>
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
</span>
@@ -127,86 +107,6 @@ onActivated(() => {
padding: 0 0 24px;
}
.announce-page--embedded {
padding: 0;
min-height: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.announce-page--embedded .list {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.embedded-header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.embedded-header .back-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #141414;
color: var(--gold);
font-size: 13px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
width: auto;
height: auto;
min-width: 0;
flex-shrink: 0;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.embedded-header .back-btn:hover {
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
}
.back-icon {
font-size: 18px;
line-height: 1;
flex-shrink: 0;
}
.back-label {
line-height: 1.2;
}
.embedded-title {
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 800;
color: var(--primary-light);
}
.announce-page--embedded .list-row {
padding: 12px 16px;
}
.announce-page--embedded .list-row:hover {
background: var(--bg-hover);
}
.list-row--active {
background: rgba(212, 175, 55, 0.08);
box-shadow: inset 3px 0 0 var(--gold);
}
.page-header {
display: flex;
align-items: center;
@@ -216,16 +116,15 @@ onActivated(() => {
margin-bottom: 0;
}
.page-header .back-btn {
.back-btn {
width: 36px;
height: 36px;
border: 1px solid var(--border);
border-radius: 10px;
background: #141414;
color: var(--gold);
background: var(--bg-card);
color: var(--primary);
font-size: 22px;
line-height: 1;
flex-shrink: 0;
cursor: pointer;
}
@@ -233,7 +132,7 @@ onActivated(() => {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
color: var(--primary-dark, #002847);
}
.state,
@@ -268,36 +167,12 @@ onActivated(() => {
min-width: 0;
}
.row-top {
display: flex;
align-items: flex-start;
gap: 8px;
}
.type-badge {
flex-shrink: 0;
margin-top: 2px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(212, 175, 55, 0.12);
border: 1px solid rgba(212, 175, 55, 0.22);
font-size: 10px;
font-weight: 700;
line-height: 1.3;
color: var(--gold);
}
.title {
flex: 1;
min-width: 0;
display: block;
font-size: 15px;
font-weight: 600;
color: #fff;
color: var(--primary-dark, #002847);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.preview {
@@ -324,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(0, 102, 204, 0.08);
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

@@ -3,7 +3,7 @@ import { ref, computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney, parseAmount } from '../utils/localeDisplay';
import { formatMoney } from '../utils/localeDisplay';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import StatusWatermark from '../components/StatusWatermark.vue';
@@ -52,8 +52,6 @@ const statusKey = computed(() => {
const statusLabel = computed(() => t(`history.status_${statusKey.value}`));
const isSettled = computed(() => statusKey.value !== 'pending');
const placedDateTime = computed(() => {
if (!bet.value) return '';
const d = new Date(bet.value.placedAt);
@@ -66,53 +64,10 @@ const returnAmount = computed(() => {
if (!bet.value) return '';
if (statusKey.value === 'won') return formatMoney(bet.value.actualReturn, locale.value);
if (statusKey.value === 'pending') return formatMoney(bet.value.potentialReturn, locale.value);
if (statusKey.value === 'lost') return formatMoney(-parseAmount(bet.value.stake), locale.value);
if (statusKey.value === 'lost') return formatMoney(-parseFloat(String(bet.value.stake ?? 0)), locale.value);
return formatMoney(bet.value.actualReturn ?? bet.value.potentialReturn, locale.value);
});
const profitAmount = computed(() => {
if (!bet.value || statusKey.value !== 'won') return '';
const profit = parseAmount(bet.value.actualReturn) - parseAmount(bet.value.stake);
return `+${formatMoney(profit, locale.value)}`;
});
const heroAmount = computed(() => {
if (statusKey.value === 'won') return profitAmount.value;
return returnAmount.value;
});
const heroTitle = computed(() => {
if (statusKey.value === 'won') return t('history.profit');
if (statusKey.value === 'pending') return t('history.est_return');
return statusLabel.value;
});
const formulaText = computed(() => {
if (!bet.value || !oddsText.value || statusKey.value === 'pending') return '';
return t('history.stake_to_return', {
stake: stakeAmount.value,
odds: oddsText.value,
amount: returnAmount.value,
});
});
const stakeAmount = computed(() =>
bet.value ? formatMoney(bet.value.stake, locale.value) : '',
);
const oddsText = computed(() => {
const o = bet.value?.totalOdds;
if (o == null || o === '' || o === 0) return '';
const n = parseFloat(String(o));
return Number.isFinite(n) ? n.toFixed(2) : String(o);
});
const betTypeLabel = computed(() => {
if (!bet.value) return '';
if (bet.value.isParlay) return t('bet.parlay');
return bet.value.betType || '';
});
function formatOdds(v: unknown): string {
const n = parseFloat(String(v));
return isNaN(n) || n <= 0 ? '-' : n.toFixed(2);
@@ -161,20 +116,13 @@ const matchTitle = computed(() => {
return bet.value.matchTitle;
});
const pickMarket = computed(() => {
const myPick = computed(() => {
if (!bet.value || bet.value.isParlay) return '';
const raw = bet.value.pickLabel ?? '';
if (locale.value === 'zh-CN' || !raw) return raw;
const ci = raw.indexOf(': ');
return ci >= 0 ? raw.slice(0, ci) : raw;
});
const pickSelection = computed(() => {
if (!bet.value || bet.value.isParlay) return '';
const raw = bet.value.pickLabel ?? '';
const ci = raw.indexOf(': ');
if (ci < 0) return '';
const sel = raw.slice(ci + 2);
return locale.value === 'zh-CN' ? sel : translateSel(sel);
if (ci < 0) return raw;
return raw.slice(0, ci + 2) + translateSel(raw.slice(ci + 2));
});
const matchPhase = computed(
@@ -182,20 +130,20 @@ const matchPhase = computed(
);
const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
const showPhaseWatermark = computed(
() => !isSettled.value && matchPhase.value && matchPhase.value !== 'open',
);
</script>
<template>
<div class="detail-page">
<div class="pull-indicator" :style="pullIndicatorStyle()">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<!-- back bar -->
<div class="top-bar">
<button type="button" class="back-btn" @click="router.back()"> {{ t('history.back') }}</button>
<button class="back-btn" @click="router.back()"> {{ t('history.back') }}</button>
</div>
<div v-if="loading" class="state">
@@ -203,131 +151,113 @@ const showPhaseWatermark = computed(
</div>
<div v-else-if="notFound || !bet" class="state">{{ t('history.not_found') }}</div>
<article v-else class="receipt">
<!-- result strip -->
<div class="receipt-result" :class="statusKey">
<div class="result-icon" :class="statusKey">
<svg v-if="statusKey === 'won'" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M7 12.5l3 3 7-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<svg v-else-if="statusKey === 'lost'" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M8 8l8 8M16 8l-8 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg v-else-if="statusKey === 'pending'" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M12 7v5l3 2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="11" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M8 12h8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</div>
<div class="result-text">
<span class="result-label">{{ heroTitle }}</span>
<span class="result-amount">{{ heroAmount }}</span>
<span v-if="statusKey === 'won'" class="result-sub">
{{ t('history.return_incl_stake', { amount: returnAmount }) }}
</span>
<span v-else-if="formulaText && statusKey !== 'pending'" class="result-sub">{{ formulaText }}</span>
<span v-else-if="statusKey === 'pending' && oddsText" class="result-sub">
{{ stakeAmount }} × {{ oddsText }}
</span>
</div>
<template v-else>
<!-- status hero -->
<div class="hero" :class="statusKey">
<span class="hero-status">{{ statusLabel }}</span>
<span class="hero-return">{{ returnAmount }}</span>
<span class="hero-return-label">
{{ statusKey === 'pending' ? t('history.est_return') : t('history.return') }}
</span>
</div>
<div class="receipt-perforation" aria-hidden="true" />
<!-- body -->
<div class="receipt-body">
<div class="receipt-meta">
<div class="meta-tags">
<span v-if="betTypeLabel" class="meta-tag">{{ betTypeLabel }}</span>
<span v-if="oddsText" class="meta-tag dim">@{{ oddsText }}</span>
<span v-if="bet.isCashbacked" class="meta-tag cashback">{{ t('history.cashbacked') }}</span>
</div>
<span class="meta-time">{{ placedDateTime }}</span>
<!-- match / parlay title -->
<section class="section">
<div class="section-head">
<span class="league-tag">
{{ bet.isParlay ? t('history.parlay_league') : (bet.leagueName || t('history.league_default')) }}
</span>
<span class="placed-time">{{ placedDateTime }}</span>
</div>
<div class="match-name">{{ matchTitle }}</div>
<h2 class="match-title">{{ matchTitle }}</h2>
<!-- single pick -->
<div v-if="!bet.isParlay" class="pick-block">
<!-- single bet: score comparison -->
<div v-if="!bet.isParlay" class="score-block" :class="{ 'score-block--phase': matchPhase && matchPhase !== 'open' }">
<StatusWatermark
v-if="showPhaseWatermark"
v-if="matchPhase && matchPhase !== 'open'"
:label="matchPhaseText"
:variant="matchPhaseVariant(matchPhase!)"
:variant="matchPhaseVariant(matchPhase)"
size="md"
/>
<span class="pick-market">{{ pickMarket }}</span>
<div class="pick-line">
<span class="pick-selection">{{ pickSelection }}</span>
<span v-if="oddsText" class="pick-odds">@{{ oddsText }}</span>
<!-- my pick row -->
<div class="row-label-val">
<span class="row-label">{{ t('history.my_pick') }}</span>
<span class="row-val pick">{{ myPick }}</span>
</div>
<div v-if="bet.matchScore?.ft || bet.matchScore?.ht" class="score-row">
<span v-if="bet.matchScore.ft" class="score-pill">
<em>{{ t('history.ft') }}</em>{{ bet.matchScore.ft }}
</span>
<span v-if="bet.matchScore.ht" class="score-pill dim">
<em>{{ t('history.ht') }}</em>{{ bet.matchScore.ht }}
</span>
</div>
<p v-else-if="statusKey === 'pending'" class="awaiting">{{ t('history.awaiting_result') }}</p>
</div>
<!-- parlay legs -->
<div v-if="bet.isParlay && bet.legs?.length" class="legs-list">
<div
v-for="(leg, i) in bet.legs"
:key="i"
class="leg-item"
:class="legStatusKey(leg.resultStatus)"
>
<div class="leg-status-dot" :class="legStatusKey(leg.resultStatus)">
<svg v-if="legStatusKey(leg.resultStatus) === 'won'" viewBox="0 0 12 12"><path d="M2 6l3 3 5-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" /></svg>
<svg v-else-if="legStatusKey(leg.resultStatus) === 'lost'" viewBox="0 0 12 12"><path d="M3 3l6 6M9 3l-6 6" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span v-else>{{ i + 1 }}</span>
<!-- scores -->
<div v-if="bet.matchScore?.ft || bet.matchScore?.ht" class="score-chips">
<div v-if="bet.matchScore.ft" class="score-item">
<span class="score-period">{{ t('history.ft') }}</span>
<span class="score-value">{{ bet.matchScore.ft }}</span>
</div>
<div class="leg-content">
<div class="leg-head">
<div v-if="bet.matchScore.ht" class="score-item">
<span class="score-period">{{ t('history.ht') }}</span>
<span class="score-value muted">{{ bet.matchScore.ht }}</span>
</div>
</div>
<div v-else-if="statusKey === 'pending'" class="no-score">{{ t('history.awaiting_result') }}</div>
</div>
</section>
<!-- parlay legs -->
<section v-if="bet.isParlay && bet.legs?.length" class="section">
<div class="section-title">{{ t('history.legs') }}</div>
<div class="legs-list">
<div v-for="(leg, i) in bet.legs" :key="i" class="leg-row" :class="legStatusKey(leg.resultStatus)">
<div class="leg-left">
<span class="leg-num" :class="legStatusKey(leg.resultStatus)">{{ i + 1 }}</span>
<div class="leg-body">
<span class="leg-match">{{ leg.matchTitle }}</span>
<span class="leg-odds">@{{ formatOdds(leg.odds) }}</span>
</div>
<span class="leg-pick">{{ leg.marketLabel }} · {{ translateSel(leg.selectionName) }}</span>
<div v-if="leg.score?.ft || leg.score?.ht" class="score-row compact">
<span v-if="leg.score.ft" class="score-pill sm"><em>{{ t('history.ft') }}</em>{{ leg.score.ft }}</span>
<span v-if="leg.score.ht" class="score-pill sm dim"><em>{{ t('history.ht') }}</em>{{ leg.score.ht }}</span>
<span class="leg-pick-line">
{{ leg.marketLabel }}: {{ translateSel(leg.selectionName) }}
</span>
<div v-if="leg.score?.ft || leg.score?.ht" class="leg-scores">
<span v-if="leg.score.ft">{{ t('history.ft') }} {{ leg.score.ft }}</span>
<span v-if="leg.score.ht" class="muted-score">{{ t('history.ht') }} {{ leg.score.ht }}</span>
</div>
</div>
</div>
<div class="leg-right">
<span class="leg-odds-val">{{ formatOdds(leg.odds) }}</span>
<span v-if="leg.resultStatus" class="leg-result-badge" :class="legStatusKey(leg.resultStatus)">
{{ t(`history.status_${legStatusKey(leg.resultStatus)}`) }}
</span>
</div>
</div>
</div>
</div>
</section>
<div class="receipt-perforation" aria-hidden="true" />
<!-- footer -->
<div class="receipt-foot">
<div class="foot-row">
<span>{{ t('history.stake') }}</span>
<span>{{ stakeAmount }}</span>
<!-- bet summary -->
<section class="section">
<div class="section-title">{{ t('history.summary') }}</div>
<div class="summary-rows">
<div class="sum-row">
<span>{{ t('history.stake') }}</span>
<span>{{ formatMoney(bet.stake, locale) }}</span>
</div>
<div v-if="bet.totalOdds" class="sum-row">
<span>{{ t('history.odds') }}</span>
<span class="odds-val">{{ formatOdds(bet.totalOdds) }}</span>
</div>
<div class="sum-row">
<span>{{ statusKey === 'pending' ? t('history.est_return') : t('history.return') }}</span>
<span :class="{ 'amt-won': statusKey === 'won', 'amt-pending': statusKey === 'pending', 'amt-lost': statusKey === 'lost' }">
{{ returnAmount }}
</span>
</div>
<div class="sum-row muted">
<span>{{ t('history.bet_no') }}</span>
<span class="bet-no-val">{{ bet.betNo }}</span>
</div>
</div>
<div v-if="bet.totalOdds" class="foot-row">
<span>{{ t('history.odds') }}</span>
<span class="gold">{{ formatOdds(bet.totalOdds) }}</span>
</div>
<div class="foot-row id">
<span>{{ t('history.bet_no') }}</span>
<span class="mono">{{ bet.betNo }}</span>
</div>
</div>
</article>
</section>
</template>
</div>
</template>
<style scoped>
.detail-page {
padding-bottom: 36px;
padding-bottom: 32px;
}
.pull-indicator {
@@ -339,396 +269,361 @@ const showPhaseWatermark = computed(
}
.top-bar {
margin-bottom: 10px;
margin-bottom: 4px;
}
.back-btn {
background: none;
border: none;
color: var(--primary-light);
color: #003D6B;
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
cursor: pointer;
display: flex;
align-items: center;
gap: 2px;
}
.state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
color: #6B7280;
font-size: 14px;
font-weight: 600;
}
/* ── receipt ticket ── */
.receipt {
position: relative;
border-radius: var(--radius);
background: rgba(18, 18, 18, 0.96);
border: 1px solid var(--border);
box-shadow: var(--shadow);
overflow: hidden;
}
/* result strip */
.receipt-result {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 18px 18px;
}
.receipt-result.won {
background: linear-gradient(135deg, rgba(52, 199, 89, 0.1) 0%, transparent 70%);
}
.receipt-result.lost {
background: linear-gradient(135deg, rgba(255, 69, 58, 0.08) 0%, transparent 70%);
}
.receipt-result.pending {
background: linear-gradient(135deg, rgba(212, 175, 55, 0.08) 0%, transparent 70%);
}
.result-icon {
flex-shrink: 0;
width: 48px;
height: 48px;
}
.result-icon svg {
width: 100%;
height: 100%;
}
.result-icon.won { color: #34c759; }
.result-icon.lost { color: var(--danger); }
.result-icon.pending { color: var(--primary-light); }
.result-icon.push { color: var(--text-muted); }
.result-text {
flex: 1;
min-width: 0;
/* ── hero ── */
.hero {
border-radius: 12px;
padding: 20px 20px 18px;
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
gap: 4px;
margin-bottom: 12px;
text-align: center;
}
.result-label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.04em;
.hero.won { background: rgba(5, 150, 105, 0.08); border: 1px solid rgba(5, 150, 105, 0.2); }
.hero.lost { background: rgba(220, 38, 38, 0.06); border: 1px solid rgba(220, 38, 38, 0.15); }
.hero.pending { background: rgba(248, 151, 31, 0.06); border: 1px solid rgba(248, 151, 31, 0.18); }
.hero.push { background: #FFFFFF; border: 1px solid #E5E7EB; }
.hero-status {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
opacity: 0.85;
}
.hero.won .hero-status { color: #059669; }
.hero.lost .hero-status { color: #DC2626; }
.hero.pending .hero-status { color: #F8971F; }
.hero.push .hero-status { color: #6B7280; }
.result-amount {
font-size: 32px;
.hero-return {
font-size: 36px;
font-weight: 900;
letter-spacing: -0.02em;
letter-spacing: -0.01em;
line-height: 1.1;
}
.hero.won .hero-return { color: #059669; }
.hero.lost .hero-return { color: #DC2626; }
.hero.pending .hero-return { color: #F8971F; }
.hero.push .hero-return { color: #6B7280; }
.receipt-result.won .result-amount { color: #34c759; }
.receipt-result.lost .result-amount { color: var(--danger); }
.receipt-result.pending .result-amount { color: var(--primary-light); }
.receipt-result.push .result-amount { color: var(--text-muted); }
.result-sub {
font-size: 12px;
.hero-return-label {
font-size: 10.5px;
color: #6B7280;
font-weight: 600;
color: var(--text-muted);
margin-top: 2px;
letter-spacing: 0.04em;
}
/* perforated divider */
.receipt-perforation {
height: 1px;
margin: 0 14px;
background: repeating-linear-gradient(
90deg,
transparent 0,
transparent 4px,
rgba(255, 255, 255, 0.08) 4px,
rgba(255, 255, 255, 0.08) 8px
);
position: relative;
/* ── sections ── */
.section {
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.receipt-perforation::before,
.receipt-perforation::after {
content: '';
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--bg-body, #000);
transform: translateY(-50%);
}
.receipt-perforation::before { left: -20px; }
.receipt-perforation::after { right: -20px; }
/* body */
.receipt-body {
padding: 16px 18px;
}
.receipt-meta {
.section-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 10px;
margin-bottom: 10px;
}
.meta-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.meta-tag {
font-size: 10px;
font-weight: 800;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 3px 8px;
border-radius: 4px;
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border: 1px solid var(--border-gold-soft);
}
.meta-tag.dim {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.04);
border-color: var(--border);
}
.meta-tag.cashback {
color: #f0b90b;
background: rgba(240, 185, 11, 0.1);
border-color: rgba(240, 185, 11, 0.25);
}
.meta-time {
flex-shrink: 0;
font-size: 11px;
color: #555;
font-weight: 600;
}
.match-title {
font-size: 16px;
font-weight: 800;
color: var(--text);
line-height: 1.35;
margin-bottom: 14px;
}
/* pick block */
.pick-block {
position: relative;
overflow: hidden;
padding: 14px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: var(--radius-sm);
}
.pick-market {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.04em;
align-items: center;
margin-bottom: 6px;
}
.pick-line {
.league-tag {
font-size: 11px;
color: #6B7280;
font-weight: 700;
}
.placed-time {
font-size: 11px;
color: #6B7280;
font-weight: 600;
}
.match-name {
font-size: 17px;
font-weight: 900;
color: #1A1A2E;
line-height: 1.3;
margin-bottom: 10px;
}
/* score block */
.score-block {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 8px;
}
.score-block--phase {
padding: 8px 0 4px;
}
.row-label-val {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
gap: 8px;
}
.pick-selection {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
.row-label {
font-size: 11px;
color: #6B7280;
font-weight: 600;
}
.pick-odds {
font-size: 14px;
font-weight: 900;
color: var(--text-muted);
.row-val {
font-size: 13px;
font-weight: 700;
color: #1A1A2E;
}
.score-row {
.row-val.pick {
color: #003D6B;
}
.score-chips {
display: flex;
gap: 8px;
margin-top: 12px;
}
.score-row.compact {
margin-top: 8px;
}
.score-pill {
font-size: 13px;
font-weight: 800;
color: var(--text);
.score-item {
display: flex;
align-items: center;
gap: 5px;
background: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 7px;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.04);
border-radius: 6px;
}
.score-pill em {
font-style: normal;
font-size: 9px;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 0.06em;
.score-period {
font-size: 10px;
color: #6B7280;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-right: 5px;
}
.score-pill.dim { color: var(--text-muted); }
.score-pill.sm { font-size: 11px; padding: 3px 8px; }
.score-value {
font-size: 16px;
font-weight: 900;
color: #1A1A2E;
}
.awaiting {
margin-top: 10px;
font-size: 12px;
color: var(--text-muted);
.score-value.muted {
color: #6B7280;
font-size: 13px;
}
.no-score {
font-size: 11.5px;
color: #6B7280;
font-style: italic;
}
/* section title */
.section-title {
font-size: 10.5px;
color: #6B7280;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-bottom: 10px;
}
/* parlay legs */
.legs-list {
display: flex;
flex-direction: column;
gap: 0;
gap: 8px;
}
.leg-item {
.leg-row {
display: flex;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
justify-content: space-between;
align-items: flex-start;
gap: 10px;
background: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 12px;
}
.leg-item:last-child {
border-bottom: none;
padding-bottom: 0;
}
.leg-row.won { border-color: rgba(5, 150, 105, 0.25); }
.leg-row.lost { border-color: rgba(220, 38, 38, 0.2); }
.leg-status-dot {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
.leg-left {
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 900;
background: rgba(255, 255, 255, 0.05);
border: 1.5px solid var(--border);
color: var(--text-muted);
margin-top: 2px;
}
.leg-status-dot svg {
width: 12px;
height: 12px;
}
.leg-status-dot.won {
background: rgba(52, 199, 89, 0.12);
border-color: rgba(52, 199, 89, 0.45);
color: #34c759;
}
.leg-status-dot.lost {
background: rgba(255, 69, 58, 0.1);
border-color: rgba(255, 69, 58, 0.35);
color: var(--danger);
}
.leg-content {
align-items: flex-start;
gap: 8px;
flex: 1;
min-width: 0;
}
.leg-head {
.leg-num {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
background: #E8EDF2;
color: #6B7280;
font-size: 10px;
font-weight: 800;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 4px;
align-items: center;
justify-content: center;
margin-top: 1px;
}
.leg-num.won { background: rgba(5, 150, 105, 0.12); color: #059669; }
.leg-num.lost { background: rgba(220, 38, 38, 0.1); color: #DC2626; }
.leg-body {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.leg-match {
font-size: 13px;
font-size: 12.5px;
font-weight: 800;
color: var(--text);
line-height: 1.35;
color: #1A1A2E;
}
.leg-odds {
flex-shrink: 0;
font-size: 13px;
font-weight: 900;
color: var(--text-muted);
}
.leg-pick {
font-size: 12px;
.leg-pick-line {
font-size: 11.5px;
color: #6B7280;
font-weight: 600;
color: var(--text-muted);
}
/* footer */
.receipt-foot {
padding: 14px 18px 16px;
.leg-scores {
display: flex;
gap: 8px;
margin-top: 2px;
}
.foot-row {
.leg-scores span {
font-size: 11px;
font-weight: 700;
color: #1A1A2E;
}
.leg-scores .muted-score {
color: #6B7280;
}
.leg-right {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.leg-odds-val {
font-size: 15px;
font-weight: 900;
color: #003D6B;
}
.leg-result-badge {
font-size: 9px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 10px;
}
.leg-result-badge.won { color: #059669; background: rgba(5, 150, 105, 0.1); }
.leg-result-badge.lost { color: #DC2626; background: rgba(220, 38, 38, 0.08); }
.leg-result-badge.push { color: #6B7280; background: #E8EDF2; }
.leg-result-badge.pending { color: #6B7280; background: #F5F7FA; }
/* summary */
.summary-rows {
display: flex;
flex-direction: column;
gap: 0;
}
.sum-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 7px 0;
padding: 8px 0;
border-bottom: 1px solid #E5E7EB;
font-size: 13px;
color: var(--text-muted);
color: #1A1A2E;
font-weight: 600;
}
.foot-row span:last-child {
color: var(--text);
font-weight: 700;
.sum-row:last-child {
border-bottom: none;
}
.foot-row .gold {
color: var(--primary-light);
font-weight: 800;
}
.foot-row.id {
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
.sum-row.muted {
color: #6B7280;
font-size: 11px;
}
.foot-row.id span:last-child {
color: #555;
font-weight: 600;
.odds-val {
color: #F8971F;
font-weight: 800;
}
.mono {
font-family: ui-monospace, monospace;
letter-spacing: 0.02em;
.amt-won {
color: #059669;
font-weight: 900;
font-size: 15px;
}
.amt-pending {
color: #F8971F;
font-weight: 900;
}
.amt-lost {
color: #DC2626;
font-weight: 900;
font-size: 15px;
}
.bet-no-val {
font-family: monospace;
letter-spacing: 0.04em;
}
</style>

View File

@@ -202,7 +202,7 @@ const pullIndicatorStyle = () => ({
.back-btn {
background: none;
border: none;
color: var(--primary-light, #d4af37);
color: #003D6B;
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
@@ -226,7 +226,7 @@ const pullIndicatorStyle = () => ({
align-items: center;
gap: 12px;
text-align: center;
color: var(--text-muted);
color: #6B7280;
padding: 56px 20px;
font-weight: 600;
font-size: 14px;
@@ -243,29 +243,29 @@ const pullIndicatorStyle = () => ({
gap: 4px;
padding: 18px 16px 16px;
margin-bottom: 14px;
border-radius: var(--radius, 12px);
background: linear-gradient(135deg, rgba(212, 175, 55, 0.14), rgba(26, 26, 26, 0.92));
border: 1px solid rgba(212, 175, 55, 0.28);
border-radius: 12px;
background: linear-gradient(135deg, rgba(0, 61, 107, 0.06), #FFFFFF);
border: 1px solid #E5E7EB;
text-align: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.hero-amount {
font-size: 28px;
font-weight: 800;
color: #f0b90b;
color: #003D6B;
line-height: 1.2;
text-shadow: 0 0 20px rgba(240, 185, 11, 0.2);
}
.hero-sub {
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
line-height: 1.4;
}
.section-title {
font-size: 10.5px;
color: #555;
color: #6B7280;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -273,16 +273,16 @@ const pullIndicatorStyle = () => ({
}
.list-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
backdrop-filter: blur(10px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.record-row {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid #E5E7EB;
}
.record-row:last-child {
@@ -290,15 +290,15 @@ const pullIndicatorStyle = () => ({
}
.record-row--highlight {
background: rgba(212, 175, 55, 0.08);
box-shadow: inset 3px 0 0 #d4af37;
background: rgba(0, 61, 107, 0.04);
box-shadow: inset 3px 0 0 #003D6B;
}
.ledger-hint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
color: #666;
color: #6B7280;
}
.row-main {
@@ -318,12 +318,12 @@ const pullIndicatorStyle = () => ({
.row-amount {
font-size: 17px;
font-weight: 800;
color: var(--primary-light);
color: #003D6B;
}
.row-period {
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
}
@@ -331,9 +331,9 @@ const pullIndicatorStyle = () => ({
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: #f0b90b;
background: rgba(240, 185, 11, 0.1);
border: 1px solid rgba(240, 185, 11, 0.22);
color: #F8971F;
background: rgba(248, 151, 31, 0.08);
border: 1px solid rgba(248, 151, 31, 0.22);
border-radius: 999px;
padding: 3px 10px;
}
@@ -344,7 +344,7 @@ const pullIndicatorStyle = () => ({
gap: 8px 16px;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
}
.row-foot {
@@ -354,9 +354,9 @@ const pullIndicatorStyle = () => ({
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed rgba(255, 255, 255, 0.06);
border-top: 1px dashed #E5E7EB;
font-size: 11px;
color: #666;
color: #6B7280;
}
.row-batch {
@@ -380,14 +380,14 @@ const pullIndicatorStyle = () => ({
margin: 0 0 8px;
font-size: 15px;
font-weight: 700;
color: var(--text-muted);
color: #6B7280;
}
.empty-hint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #666;
color: #6B7280;
}
.sentinel {
@@ -403,7 +403,7 @@ const pullIndicatorStyle = () => ({
.end-hint {
text-align: center;
font-size: 12px;
color: #555;
color: #6B7280;
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;

View File

@@ -146,9 +146,6 @@ const availableLeagues = computed(() => {
timeMatch = isAfterTodayMatchWindow(m.startTime, filterNow.value);
}
if (!timeMatch) continue;
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) continue;
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') continue;
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
@@ -256,13 +253,6 @@ onActivated(() => {
filterNow.value = new Date();
filterState.value.time = 'all';
syncSearchFromRoute();
if (route.query.tab === 'outright') {
selectMainTab('outright');
} else {
selectMainTab('matches');
}
void loadSummary(true, true);
});
@@ -292,7 +282,6 @@ function goMatch(id: string) {
:class="{ active: mainTab === 'matches', 'tab-gold-active': mainTab === 'matches' }"
@click="selectMainTab('matches')"
>
<span class="tab-icon"></span>
{{ t('bet.tab_matches') }}
</button>
<button
@@ -301,7 +290,6 @@ function goMatch(id: string) {
:class="{ active: mainTab === 'outright', 'tab-gold-active': mainTab === 'outright' }"
@click="selectMainTab('outright')"
>
<span class="tab-icon">🏆</span>
{{ t('bet.tab_outright') }}
</button>
</div>
@@ -504,71 +492,24 @@ function goMatch(id: string) {
color: var(--primary-light);
}
.tab-icon {
font-size: 16px;
line-height: 1;
}
.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(--gold);
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: #fff;
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);
}
.filters-bar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 0 12px 12px;
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 {
@@ -577,16 +518,15 @@ function goMatch(id: string) {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
height: 24px;
padding: 2px 3px;
height: 26px;
box-sizing: border-box;
flex-shrink: 0;
}
.time-tab {
padding: 0 8px;
padding: 0 10px;
height: 100%;
font-size: 11px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
@@ -597,8 +537,6 @@ function goMatch(id: string) {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
}
.time-tab.active {
@@ -614,8 +552,8 @@ function goMatch(id: string) {
}
.status-toggle-btn {
padding: 0 8px;
height: 24px;
padding: 0 10px;
height: 26px;
box-sizing: border-box;
font-size: 11px;
font-weight: 500;
@@ -628,8 +566,6 @@ function goMatch(id: string) {
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
}
.status-toggle-btn.active {
@@ -643,15 +579,14 @@ function goMatch(id: string) {
.league-dropdown {
position: relative;
display: inline-block;
width: 100%;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
font-size: 11px;
padding: 0 12px;
font-size: 12px;
font-weight: 500;
color: var(--text);
background: var(--bg-card);
@@ -660,8 +595,7 @@ function goMatch(id: string) {
cursor: pointer;
height: 24px;
box-sizing: border-box;
width: 100%;
max-width: 100%;
max-width: 150px;
}
.dropdown-text {
@@ -699,37 +633,32 @@ function goMatch(id: string) {
max-width: 240px;
max-height: 200px;
overflow-y: auto;
background: #0f1217;
border: 1px solid #2a3240;
background: #FFFFFF;
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.65);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.35);
padding: 6px 0;
}
.dropdown-item {
padding: 8px 14px;
font-size: 13px;
color: #e5e7eb;
color: var(--text);
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #1f2937;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover,
.dropdown-item:active {
background: #1b2330;
background: var(--bg-body);
}
.dropdown-item.active {
color: #f4d06f;
background: #242f42;
color: var(--primary-light);
background: rgba(244, 162, 97, 0.1);
font-weight: 700;
}
@@ -737,6 +666,68 @@ function goMatch(id: string) {
padding: 4px 12px 0;
}
.state,
.placeholder,
.empty {
text-align: center;
color: var(--text-muted);
padding: 48px 20px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
}
.placeholder {
padding: 80px 20px;
}
.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;
}
@@ -768,27 +759,4 @@ function goMatch(id: string) {
color: var(--primary-light);
line-height: 1.3;
}
.state,
.placeholder,
.empty {
text-align: center;
color: var(--text-muted);
padding: 48px 20px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
}
.placeholder {
padding: 80px 20px;
}
.outright-tab {
min-height: 0;
}
</style>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts">
import { onActivated, computed, ref } from 'vue';
import { onActivated, computed, ref, watch, onDeactivated } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import vsImg from '../assets/images/vs.png';
import cardBg from '../assets/images/card-bg.webp';
import bentoBetBg from '../assets/images/bento-bet-bg.png';
import BannerCarousel from '../components/BannerCarousel.vue';
import HomeAnnouncementCard from '../components/HomeAnnouncementCard.vue';
import { usePlayerHome } from '../composables/usePlayerHome';
import TeamEmblem from '../components/TeamEmblem.vue';
import GoldSpinner from '../components/GoldSpinner.vue';
@@ -17,29 +17,82 @@ import type { PlayerHomeMatch } from '../composables/usePlayerHome';
type HotTab = 'hot' | 'upcoming';
const matchCardBg = `url(${cardBg})`;
const bentoBetCardBg = `url(${bentoBetBg})`;
const { t, locale } = useI18n();
const router = useRouter();
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
const activeTab = ref<HotTab>('hot');
const activeFeaturedIndex = ref(0);
const touchStartX = ref(0);
const touchDeltaX = ref(0);
let rotateInterval: number | undefined;
function startFeaturedRotation() {
stopFeaturedRotation();
if (hotMatches.value.length > 1) {
rotateInterval = window.setInterval(() => {
activeFeaturedIndex.value = (activeFeaturedIndex.value + 1) % hotMatches.value.length;
}, 4500);
}
}
function stopFeaturedRotation() {
if (rotateInterval) {
window.clearInterval(rotateInterval);
rotateInterval = undefined;
}
}
watch(() => hotMatches.value.length, () => {
activeFeaturedIndex.value = 0;
startFeaturedRotation();
});
function onFeaturedTouchStart(e: TouchEvent) {
touchStartX.value = e.touches[0].clientX;
touchDeltaX.value = 0;
stopFeaturedRotation();
}
function onFeaturedTouchMove(e: TouchEvent) {
touchDeltaX.value = e.touches[0].clientX - touchStartX.value;
}
function onFeaturedTouchEnd() {
const threshold = 40;
if (hotMatches.value.length > 1) {
if (touchDeltaX.value > threshold) {
activeFeaturedIndex.value = (activeFeaturedIndex.value - 1 + hotMatches.value.length) % hotMatches.value.length;
} else if (touchDeltaX.value < -threshold) {
activeFeaturedIndex.value = (activeFeaturedIndex.value + 1) % hotMatches.value.length;
}
}
touchDeltaX.value = 0;
startFeaturedRotation();
}
const bannerFallbackTo = computed(() => {
const id = announcementItems.value[0]?.id;
return id ? `/announcements/${id}` : '/announcements';
});
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
);
const displayedMatches = computed<PlayerHomeMatch[]>(() => upcomingMatches.value);
const limitedMatches = computed(() => displayedMatches.value.slice(0, 6));
const emptyMessage = computed(() =>
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
);
const emptyMessage = computed(() => t('home.upcoming_empty'));
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await load(true); },
});
onActivated(() => { void load(true); });
onActivated(() => {
void load(true);
startFeaturedRotation();
});
onDeactivated(() => {
stopFeaturedRotation();
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
@@ -67,37 +120,129 @@ function formatKickoff(startTime: string) {
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<div class="home-card-wrapper">
<HomeAnnouncementCard />
<div class="quick-bento">
<div
class="bento-item bento-item--large bento-item--featured-carousel"
@touchstart.passive="onFeaturedTouchStart"
@touchmove.passive="onFeaturedTouchMove"
@touchend="onFeaturedTouchEnd"
>
<!-- Fixed header, does not swipe/scroll -->
<div v-if="hotMatches.length" class="bento-header-row fixed-header">
<span class="featured-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="12" height="12" class="tag-icon"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ t('home.hot_matches') }}
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="carousel-viewport">
<div
class="carousel-track"
:style="{ transform: `translateX(-${activeFeaturedIndex * 100}%)` }"
>
<template v-if="hotMatches.length">
<div
v-for="match in hotMatches"
:key="match.id"
class="carousel-slide"
@click="goMatch(match.id)"
>
<div class="bento-content--vertical">
<div class="bento-header-row placeholder-header" aria-hidden="true">
<span class="featured-tag">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="12" height="12" class="tag-icon"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{{ t('home.hot_matches') }}
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="featured-vs-area">
<div class="featured-team">
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="featured-team-name">{{ match.homeTeamName }}</span>
</div>
<span class="featured-vs">VS</span>
<div class="featured-team">
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="featured-team-name">{{ match.awayTeamName }}</span>
</div>
</div>
<div class="featured-footer">
<span class="featured-time">{{ formatKickoff(match.startTime) }}</span>
<span class="featured-action-btn">{{ t('announcements.go_link') }} </span>
</div>
</div>
</div>
</template>
<div v-else class="carousel-slide" @click="router.push('/bet')">
<div class="bento-content--vertical">
<div class="bento-header-row">
<span class="bento-icon bento-icon--bet">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="22" height="22"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 000 20 14.5 14.5 0 000-20M2 12h20"/></svg>
</span>
<span class="bento-badge">HOT</span>
</div>
<div class="bento-text">
<span class="bento-title bento-title--lg">{{ t('nav.bet') }}</span>
<span class="bento-desc">{{ t('home.bento_desc') }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="hotMatches.length > 1" class="carousel-dots">
<span
v-for="(_, i) in hotMatches"
:key="i"
class="carousel-dot"
:class="{ active: i === activeFeaturedIndex }"
/>
</div>
</div>
<button type="button" class="bento-item bento-item--small" @click="router.push('/wallet/recharge')">
<span class="bento-icon bento-icon--recharge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M12 15h4"/></svg>
</span>
<span class="bento-title bento-title--sm">{{ t('recharge.title') }}</span>
</button>
<button type="button" class="bento-item bento-item--small" @click="router.push('/bets')">
<span class="bento-icon bento-icon--history">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<path d="M6 4h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z" stroke-linejoin="round"/>
<path d="M8 9h8M8 12.5h8M8 16h5" stroke-linecap="round"/>
</svg>
</span>
<span class="bento-title bento-title--sm">{{ t('home.view_bet_history') }}</span>
</button>
</div>
<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 class="section-header">
<div class="section-title">{{ t('home.upcoming_tab') }}</div>
</div>
<div
v-for="(match, index) in displayedMatches"
v-for="(match, index) in limitedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
:class="{ 'match-card--live-anim': index < 3 }"
@click="goMatch(match.id)"
>
<div class="match-info">
@@ -145,6 +290,12 @@ function formatKickoff(startTime: string) {
</div>
</div>
<div v-if="displayedMatches.length > 6" class="view-all-container">
<button type="button" class="view-all-link" @click="router.push('/bet')">
{{ t('home.view_all_matches') }}
</button>
</div>
<div v-if="!loading && !displayedMatches.length" class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ emptyMessage }}</p>
@@ -153,10 +304,6 @@ function formatKickoff(startTime: string) {
</template>
<style scoped>
.home-card-wrapper {
margin: 12px 16px;
}
.pull-indicator {
display: flex;
align-items: center;
@@ -165,34 +312,334 @@ function formatKickoff(startTime: string) {
transition: height 0.15s ease;
}
.hot-tabs {
display: flex;
.section-header {
margin-top: 14px;
margin-bottom: 10px;
}
.quick-bento {
display: grid;
grid-template-columns: 1.2fr 1fr;
grid-template-rows: 54px 54px;
gap: 8px;
margin: 14px 0;
margin-bottom: 16px;
padding: 0 2px;
}
.hot-tab {
flex: 1;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
transition: background 0.2s, border-color 0.2s, color 0.2s;
.bento-item {
display: flex;
align-items: center;
border-radius: 12px;
border: 1px solid #E5E7EB;
background: #FFFFFF;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
padding: 10px 12px;
text-decoration: none;
box-sizing: border-box;
}
.hot-tab.active {
.bento-item:active {
transform: scale(0.97);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.bento-item--large {
grid-row: 1 / 3;
height: 100%;
background: linear-gradient(135deg, #F3F8FC 0%, #FFFFFF 100%);
border-color: rgba(0, 61, 107, 0.15);
position: relative;
overflow: hidden;
padding: 0;
}
.bento-item--featured-carousel {
/* Carousel container styles */
}
.carousel-viewport {
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
}
.carousel-track {
display: flex;
height: 100%;
width: 100%;
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
}
.carousel-slide {
flex: 0 0 100%;
min-width: 100%;
height: 100%;
padding: 10px 10px 8px 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.carousel-dots {
position: absolute;
bottom: 4px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 4px;
z-index: 10;
pointer-events: none;
}
.carousel-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: rgba(0, 61, 107, 0.15);
transition: background 0.25s, width 0.25s;
}
.carousel-dot.active {
width: 12px;
border-radius: 2px;
background: #003D6B;
}
.featured-tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 9px;
font-weight: 700;
background: rgba(244, 162, 97, 0.1);
border-color: var(--primary);
color: var(--primary-light);
color: #003D6B;
background: rgba(0, 61, 107, 0.06);
padding: 3px 6px;
border-radius: 6px;
line-height: 1;
}
.hot-tab:active {
opacity: 0.92;
.tag-icon {
color: #F97316;
}
.featured-vs-area {
display: flex;
align-items: center;
justify-content: space-around;
width: 100%;
margin: 6px 0;
gap: 4px;
}
.featured-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
flex: 1;
min-width: 0;
}
.featured-team-name {
font-size: 9px;
font-weight: 800;
color: #1A1A2E;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.featured-vs {
font-size: 11px;
font-weight: 800;
color: #F97316;
font-style: italic;
opacity: 0.95;
flex-shrink: 0;
}
.featured-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
border-top: 1px dashed rgba(0, 61, 107, 0.1);
padding-top: 5px;
margin-top: 2px;
}
.featured-time {
font-size: 8px;
color: #6B7280;
font-weight: 600;
}
.featured-action-btn {
font-size: 8.5px;
font-weight: 800;
color: #003D6B;
background: rgba(0, 61, 107, 0.05);
padding: 2px 6px;
border-radius: 4px;
transition: background 0.15s;
}
.bento-item--featured:active .featured-action-btn {
background: rgba(0, 61, 107, 0.12);
}
.bento-item--large .bento-title {
color: #003D6B;
}
.bento-item--large .bento-desc {
color: #6B7280;
}
.bento-item--large .bento-badge {
background: #F97316;
color: #FFFFFF;
}
.bento-content--vertical {
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
width: 100%;
}
.bento-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
width: 100%;
}
.fixed-header {
position: absolute;
top: 10px;
left: 12px;
right: 10px;
width: auto;
z-index: 20;
pointer-events: none;
}
.placeholder-header {
visibility: hidden;
pointer-events: none;
}
.bento-badge {
font-size: 8px;
font-weight: 800;
color: #FFFFFF;
background: #003D6B;
padding: 2px 5px;
border-radius: 4px;
letter-spacing: 0.05em;
line-height: 1;
transform: translateY(2px);
}
.bento-item--large:active {
border-color: rgba(0, 61, 107, 0.3);
}
.bento-item--small {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
justify-content: flex-start;
height: 100%;
}
.bento-content {
display: flex;
align-items: center;
gap: 10px;
}
.bento-icon {
width: 34px;
height: 34px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.bento-item--large .bento-icon {
width: 40px;
height: 40px;
border-radius: 10px;
}
.bento-icon--bet {
background: rgba(0, 61, 107, 0.08);
color: #003D6B;
}
.bento-icon--recharge {
background: rgba(5, 150, 105, 0.08);
color: #059669;
}
.bento-icon--history {
background: rgba(248, 151, 31, 0.08);
color: #E8870A;
}
.bento-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1px;
}
.bento-title {
font-size: 13px;
font-weight: 800;
color: #1A1A2E;
text-align: left;
}
.bento-title--sm {
font-size: 12px;
font-weight: 700;
}
.bento-title--lg {
font-size: 14.5px;
font-weight: 800;
margin-bottom: 2px;
}
.bento-desc {
font-size: 9.5px;
color: #6B7280;
font-weight: 500;
text-align: left;
}
.section-title {
font-size: 15px;
font-weight: 700;
color: #1A1A2E;
margin: 0 0 8px;
padding-left: 10px;
border-left: 3px solid #003D6B;
line-height: 1.3;
}
.match-card {
@@ -201,16 +648,16 @@ 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: 8px;
padding: 10px 12px;
min-height: 60px;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
background: #FFFFFF;
cursor: pointer;
transition: opacity 0.2s, transform 0.2s;
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
@@ -218,15 +665,14 @@ function formatKickoff(startTime: string) {
content: '';
position: absolute;
inset: 0;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
opacity: 0.25;
background: linear-gradient(135deg, rgba(0, 61, 107, 0.02) 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.06);
}
.match-info,
@@ -241,15 +687,16 @@ 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: #1A1A2E;
}
.match-time {
font-size: 13px;
color: var(--text-muted);
font-size: 12px;
color: #6B7280;
font-weight: 500;
}
@@ -257,15 +704,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;
@@ -286,7 +733,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(0, 102, 204, 0.6)) drop-shadow(0 0 8px rgba(0, 91, 159, 0.35));
opacity: 0;
}
@@ -311,11 +758,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(0, 91, 159, 0) 0%,
rgba(0, 102, 204, 0.7) 28%,
#0066CC 50%,
rgba(0, 91, 159, 0.7) 72%,
rgba(0, 61, 107, 0) 100%
);
opacity: 0;
filter: blur(0.4px);
@@ -329,10 +776,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(0, 61, 107, 0.25));
}
.match-card--live-anim .vs-img {
@@ -420,21 +867,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(0, 61, 107, 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(0, 102, 204, 0.5))
drop-shadow(0 0 6px rgba(0, 61, 107, 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(0, 61, 107, 0.25));
}
.hz-path,
@@ -446,14 +893,48 @@ function formatKickoff(startTime: string) {
.empty {
text-align: center;
color: var(--text-muted);
padding: 40px 20px;
color: #6B7280;
padding: 28px 16px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
width: 72px;
height: 72px;
margin-bottom: 10px;
}
.view-all-container {
display: flex;
justify-content: center;
margin-top: 12px;
margin-bottom: 24px;
padding: 0 2px;
}
.view-all-link {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: none;
font-family: inherit;
font-size: 13px;
font-weight: 700;
color: #003D6B;
cursor: pointer;
padding: 6px 12px;
outline: none;
transition: all 0.2s ease;
}
.view-all-link:hover {
opacity: 0.85;
text-decoration: underline;
}
.view-all-link:active {
transform: scale(0.97);
opacity: 0.7;
}
</style>

View File

@@ -159,7 +159,7 @@ onActivated(() => {
</button>
</div>
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" @click-message="id => $router.push('/messages/' + id)" />
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
<CustomerServicePanel v-if="activeTab === 'support'" />
<ConfirmDialog
@@ -201,8 +201,8 @@ onActivated(() => {
height: 36px;
border: 1px solid var(--border);
border-radius: 10px;
background: #141414;
color: var(--gold);
background: var(--bg-card);
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
@@ -212,7 +212,7 @@ onActivated(() => {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
color: var(--primary-dark, #002847);
}
.hub-top-bar {
@@ -248,8 +248,8 @@ onActivated(() => {
}
.hub-tab.active {
color: var(--gold);
border-bottom-color: var(--gold);
color: var(--primary);
border-bottom-color: var(--primary);
}
.hub-tab-label {
@@ -260,8 +260,8 @@ onActivated(() => {
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--gold);
color: #111;
background: var(--primary);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 18px;
@@ -277,10 +277,10 @@ onActivated(() => {
.action-btn {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
border-radius: 8px;
background: rgba(212, 175, 55, 0.08);
color: var(--gold);
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -292,8 +292,8 @@ onActivated(() => {
}
.action-btn.danger {
border-color: rgba(255, 80, 80, 0.35);
background: rgba(255, 80, 80, 0.08);
color: #f88;
border-color: rgba(220, 38, 38, 0.35);
background: rgba(220, 38, 38, 0.06);
color: #dc2626;
}
</style>

View File

@@ -453,7 +453,9 @@ function onPickSelection(selId: string, marketId: string) {
<style scoped>
.detail-page {
margin: 0 -16px;
padding-top: 0;
padding-bottom: 16px;
background: #E8EDF2;
}
.pull-indicator {
@@ -471,19 +473,19 @@ function onPickSelection(selId: string, marketId: string) {
padding: 4px 12px 8px;
gap: 8px;
position: sticky;
top: -12px;
top: 0;
z-index: 50;
margin-top: -12px;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(4px);
margin-bottom: 8px;
background: #FFFFFF;
border-bottom: 1px solid #CBD5E1;
}
.toolbar-title {
flex: 1;
text-align: center;
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
font-weight: 700;
color: #003D6B;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -501,9 +503,9 @@ 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);
color: var(--primary-light);
border: 1px solid #E5E7EB;
background: #F5F7FA;
color: #003D6B;
font-size: 16px;
font-weight: 700;
line-height: 1;
@@ -514,75 +516,77 @@ function onPickSelection(selId: string, marketId: string) {
opacity: 0.45;
}
/* ── match hero ── */
/* -- match hero -- */
.match-hero {
position: relative;
isolation: isolate;
margin: 0 0 10px;
margin: 0 12px 10px;
padding: 14px 12px 16px;
overflow: hidden;
background: #FFFFFF;
border: 1px solid #CBD5E1;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.match-hero--phase {
opacity: 0.98;
}
/* ── hero status tag (top-right corner) ── */
/* -- hero status tag (top-right corner) -- */
.status-tag {
position: absolute;
top: 0;
right: 0;
z-index: 5;
font-size: 11px;
font-weight: 800;
font-weight: 700;
padding: 4px 14px;
border-radius: 0 6px 0 10px;
line-height: 1.3;
white-space: nowrap;
letter-spacing: 0.02em;
}
.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: #E8EDF2;
color: #4B5563;
border-bottom: 1px solid #D1D5DB;
border-left: 1px solid #D1D5DB;
}
.status-tag--pending {
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
color: #c8a84e;
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
border-left: 1px solid rgba(200, 168, 78, 0.3);
background: #FEF3C7;
color: #92400E;
border-bottom: 1px solid #FDE68A;
border-left: 1px solid #FDE68A;
}
/* ── 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: #E8EDF2;
color: #4B5563;
border-bottom: 1px solid #D1D5DB;
border-left: 1px solid #D1D5DB;
}
.market-status-tag--pending {
background: linear-gradient(180deg, #3a3a2a 0%, #28251a 100%);
color: #c8a84e;
border-bottom: 1px solid rgba(200, 168, 78, 0.3);
border-left: 1px solid rgba(200, 168, 78, 0.3);
background: #FEF3C7;
color: #92400E;
border-bottom: 1px solid #FDE68A;
border-left: 1px solid #FDE68A;
}
.market-status-tag--suspended {
@@ -612,7 +616,7 @@ function onPickSelection(selId: string, marketId: string) {
position: relative;
z-index: 1;
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
text-align: left;
margin-top: 10px;
padding-left: 2px;
@@ -624,18 +628,17 @@ function onPickSelection(selId: string, marketId: string) {
margin: 8px 0 0;
font-size: 28px;
font-weight: 900;
color: #fff;
color: #003D6B;
text-align: center;
letter-spacing: 0.06em;
}
.result-stats-section {
margin: 0 10px 12px;
padding: 12px;
border: 1px solid rgba(212, 175, 55, 0.22);
border: 1px solid #CBD5E1;
border-radius: 8px;
background: linear-gradient(180deg, #1b1b1b 0%, #121212 100%);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
background: #FFFFFF;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.result-stats-head {
@@ -650,8 +653,8 @@ function onPickSelection(selId: string, marketId: string) {
margin: 0;
font-size: 14px;
line-height: 1.2;
font-weight: 900;
color: var(--primary-light);
font-weight: 700;
color: #003D6B;
}
.result-stats-head span {
@@ -661,7 +664,7 @@ function onPickSelection(selId: string, marketId: string) {
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
}
.result-stats-grid {
@@ -678,8 +681,8 @@ function onPickSelection(selId: string, marketId: string) {
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
background: #F5F7FA;
border: 1px solid #E5E7EB;
}
.result-stat-label {
@@ -688,15 +691,15 @@ function onPickSelection(selId: string, marketId: string) {
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
}
.result-stat-value {
flex-shrink: 0;
font-size: 14px;
line-height: 1;
font-weight: 900;
color: #fff;
font-weight: 700;
color: #1A1A2E;
}
.hero-teams {
@@ -719,8 +722,8 @@ function onPickSelection(selId: string, marketId: string) {
.hero-name {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
font-weight: 700;
color: #1A1A2E;
text-align: center;
line-height: 1.25;
white-space: nowrap;
@@ -729,7 +732,7 @@ function onPickSelection(selId: string, marketId: string) {
max-width: 100%;
}
/* VS arena — same as HomeView */
/* VS arena -- blue themed */
.vs-arena {
position: relative;
flex-shrink: 0;
@@ -755,7 +758,7 @@ function onPickSelection(selId: string, marketId: 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(0, 102, 204, 0.6)) drop-shadow(0 0 8px rgba(0, 91, 159, 0.35));
opacity: 0;
}
@@ -780,11 +783,11 @@ function onPickSelection(selId: string, marketId: 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(0, 91, 159, 0) 0%,
rgba(0, 102, 204, 0.7) 28%,
#0066CC 50%,
rgba(0, 91, 159, 0.7) 72%,
rgba(0, 61, 107, 0) 100%
);
opacity: 0;
filter: blur(0.4px);
@@ -826,11 +829,11 @@ function onPickSelection(selId: string, marketId: string) {
@keyframes vs-glow {
0%, 100% {
opacity: 0.82;
filter: drop-shadow(0 0 2px rgba(212, 175, 55, 0.3));
filter: drop-shadow(0 0 2px rgba(0, 61, 107, 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));
filter: drop-shadow(0 0 3px rgba(0, 102, 204, 0.5)) drop-shadow(0 0 6px rgba(0, 61, 107, 0.25));
}
}
@@ -845,22 +848,22 @@ function onPickSelection(selId: string, marketId: string) {
.market-list {
display: flex;
flex-direction: column;
gap: 6px;
gap: 8px;
padding: 4px 0;
}
.market-group {
border-radius: 8px;
border: 1px solid #252525;
background: linear-gradient(180deg, #1a1a1a 0%, #151515 100%);
border: 1px solid #CBD5E1;
background: #FFFFFF;
overflow: hidden;
box-shadow: 0 2px 10px rgba(0,0,0,0.35);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: border-color 0.2s, box-shadow 0.2s;
}
.market-group.open {
border-color: rgba(212, 175, 55, 0.25);
box-shadow: 0 4px 16px rgba(212, 175, 55, 0.08);
border-color: #9CA3AF;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.market-group :deep(.row) {
@@ -876,28 +879,28 @@ 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: rgba(212, 175, 55, 0.1);
color: var(--primary-light);
border-radius: 6px;
border: 1px solid #E5E7EB;
background: #F5F7FA;
color: #003D6B;
font-size: 13px;
font-weight: 800;
font-weight: 700;
}
.state {
text-align: center;
padding: 48px;
color: var(--text-muted);
color: #6B7280;
}
.cs-toast {
text-align: center;
font-size: 12px;
color: var(--primary-light);
color: #003D6B;
padding: 2px 0 6px;
}
/* ── 我的投注 ── */
/* -- my bets -- */
.my-bets-section {
padding: 0 12px;
margin-bottom: 12px;
@@ -906,7 +909,7 @@ function onPickSelection(selId: string, marketId: string) {
.my-bets-title {
font-size: 14px;
font-weight: 700;
color: #ccc;
color: #1A1A2E;
margin: 0 0 8px;
}
@@ -917,16 +920,17 @@ function onPickSelection(selId: string, marketId: string) {
}
.my-bet-card {
background: #1c1c1c;
border: 1px solid #2e2e2e;
background: #FFFFFF;
border: 1px solid #CBD5E1;
border-radius: 8px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.my-bet-card:active {
background: #252525;
background: #F5F7FA;
}
.bet-header {
@@ -939,7 +943,7 @@ function onPickSelection(selId: string, marketId: string) {
.bet-type {
font-size: 12px;
font-weight: 700;
color: #fff;
color: #1A1A2E;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -956,18 +960,18 @@ function onPickSelection(selId: string, marketId: string) {
}
.bet-status-won {
background: rgba(61, 184, 101, 0.15);
color: #3db865;
background: #E6F4EA;
color: #059669;
}
.bet-status-lost {
background: rgba(224, 80, 80, 0.15);
color: #e05050;
background: #FDE8E8;
color: #DC2626;
}
.bet-status-pending {
background: rgba(232, 200, 74, 0.15);
color: #e8c84a;
background: #FEF3C7;
color: #D97706;
}
.bet-footer {
@@ -978,12 +982,12 @@ function onPickSelection(selId: string, marketId: string) {
.bet-stake {
font-size: 11px;
color: #888;
color: #6B7280;
}
.bet-return {
font-size: 13px;
font-weight: 800;
font-weight: 700;
}
</style>

View File

@@ -1,14 +1,12 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref, watch } from 'vue';
const props = defineProps<{ embedded?: boolean; id?: string }>();
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import { formatMoney } from '../utils/localeDisplay';
import GoldSpinner from '../components/GoldSpinner.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import { sanitizeAnnouncementHtml } from '../utils/html';
import { formatMoney } from '../utils/localeDisplay';
import {
usePlayerMessages,
type DepositMessagePayload,
@@ -17,14 +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 emit = defineEmits<{ (e: 'back'): void }>();
const messageId = computed(() => props.id || 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);
@@ -52,14 +62,22 @@ const contentPromoId = computed(() => {
});
function goBack() {
if (props.embedded) {
if (isPopupEmbedded.value) {
emit('back');
} else {
router.back();
return;
}
if (isHubEmbedded.value) {
router.push(hubRoute.value);
return;
}
router.back();
}
function goList() {
if (isPopupEmbedded.value) {
emit('back');
return;
}
router.push(hubRoute.value);
}
@@ -131,7 +149,7 @@ async function confirmDelete() {
try {
await deleteMessage(messageId.value);
deleteConfirmVisible.value = false;
if (props.embedded) {
if (isPopupEmbedded.value) {
emit('back');
} else {
router.replace(hubRoute.value);
@@ -155,8 +173,8 @@ watch(messageId, () => {
</script>
<template>
<div class="message-detail" :class="{ 'message-detail--embedded': embedded }">
<header v-if="!embedded" 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
@@ -170,10 +188,14 @@ watch(messageId, () => {
{{ t('messages.delete') }}
</button>
</header>
<div v-else-if="message" class="embedded-actions">
<button type="button" class="back-btn embedded-back-btn" :aria-label="t('messages.back')" @click="goBack"></button>
<h1 class="embedded-title">{{ t('messages.detail_title') }}</h1>
<header v-else class="page-header page-header--hub">
<button type="button" class="back-btn" :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')"
@@ -182,7 +204,7 @@ watch(messageId, () => {
>
{{ t('messages.delete') }}
</button>
</div>
</header>
<div v-if="loading && !message" class="state">
<GoldSpinner :size="36" />
@@ -190,7 +212,9 @@ watch(messageId, () => {
<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">
@@ -251,27 +275,6 @@ watch(messageId, () => {
padding: 0 0 24px;
}
.message-detail--embedded {
padding: 0 24px 24px;
min-height: 0;
}
.embedded-actions {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0 12px;
border-bottom: 1px solid var(--border);
}
.embedded-title {
margin: 0;
flex: 1;
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
}
.page-header {
display: flex;
align-items: center;
@@ -281,31 +284,15 @@ watch(messageId, () => {
}
.back-btn {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
border: 1px solid var(--border);
border-radius: 8px;
background: #141414;
color: var(--text-muted);
font-size: 20px;
border-radius: 10px;
background: var(--bg-card);
color: var(--primary);
font-size: 22px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s, background 0.2s;
}
.back-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.05);
}
.embedded-back-btn {
border: none;
background: transparent;
width: 28px;
height: 28px;
margin-right: -4px;
cursor: pointer;
}
.page-header h1 {
@@ -313,17 +300,18 @@ watch(messageId, () => {
flex: 1;
font-size: 18px;
font-weight: 700;
color: #fff;
color: var(--primary-dark, #002847);
}
.delete-header-btn {
border: 1px solid rgba(255, 80, 80, 0.35);
border: 1px solid rgba(220, 38, 38, 0.35);
border-radius: 8px;
padding: 6px 10px;
background: rgba(255, 80, 80, 0.08);
color: #f88;
background: rgba(220, 38, 38, 0.06);
color: #dc2626;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.delete-header-btn:disabled {
@@ -337,13 +325,13 @@ watch(messageId, () => {
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: #888;
background: rgba(255, 255, 255, 0.06);
color: var(--text-muted);
background: rgba(0, 0, 0, 0.05);
}
.status-badge.unread {
color: var(--gold);
background: rgba(212, 175, 55, 0.12);
color: var(--primary);
background: rgba(0, 61, 107, 0.08);
}
.state,
@@ -357,13 +345,14 @@ watch(messageId, () => {
}
.link-btn {
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.detail-article {
@@ -381,14 +370,14 @@ watch(messageId, () => {
font-size: 20px;
font-weight: 700;
line-height: 1.45;
color: #fff;
color: var(--primary-dark, #002847);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #d0d0d0;
color: #374151;
}
.rich-body :deep(p) {
@@ -414,21 +403,21 @@ watch(messageId, () => {
}
.rich-body :deep(a) {
color: var(--gold);
color: var(--primary);
}
.reason-box {
margin-top: 18px;
padding: 14px;
border-radius: 10px;
border: 1px solid rgba(255, 107, 107, 0.25);
background: rgba(255, 107, 107, 0.08);
border: 1px solid rgba(220, 38, 38, 0.2);
background: rgba(220, 38, 38, 0.05);
}
.reason-label {
margin: 0 0 8px;
font-size: 12px;
color: #ffb4b4;
color: #dc2626;
font-weight: 700;
}
@@ -436,19 +425,47 @@ watch(messageId, () => {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: #ffe0e0;
color: #b91c1c;
}
.action-btn {
margin-top: 24px;
width: 100%;
padding: 11px 14px;
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
border-radius: 8px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: block;
}
.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

@@ -21,14 +21,13 @@ const statusFilter = ref('');
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const STATUS_FILTERS = [
const FILTERS = [
{ key: '', label: 'history.filter_all' },
{ key: 'WON', label: 'history.filter_won' },
{ key: 'LOST', label: 'history.filter_lost' },
{ key: 'PENDING', label: 'history.filter_pending' },
] as const;
const PUSH_FILTER = { key: 'PUSH', label: 'history.filter_push' } as const;
{ key: 'PUSH', label: 'history.filter_push' },
];
async function loadPage(p: number) {
if (loading.value) return;
@@ -117,7 +116,7 @@ const pullIndicatorStyle = () => ({
<div class="filter-tabs">
<button
v-for="f in STATUS_FILTERS"
v-for="f in FILTERS"
:key="f.key"
type="button"
class="filter-tab"
@@ -126,16 +125,6 @@ const pullIndicatorStyle = () => ({
>
{{ t(f.label) }}
</button>
<span class="filter-divider" aria-hidden="true" />
<button
type="button"
class="filter-tab filter-tab--push"
:class="{ active: statusFilter === PUSH_FILTER.key }"
:title="t('history.filter_push_hint')"
@click="changeFilter(PUSH_FILTER.key)"
>
{{ t(PUSH_FILTER.label) }}
</button>
</div>
<template v-if="items.length">
@@ -175,23 +164,10 @@ const pullIndicatorStyle = () => ({
gap: 6px;
margin-bottom: 12px;
overflow-x: auto;
align-items: center;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
}
.filter-divider {
width: 1px;
height: 18px;
background: rgba(255, 255, 255, 0.14);
flex-shrink: 0;
margin: 0 2px;
}
.filter-tab--push {
border-style: dashed;
}
.filter-tabs::-webkit-scrollbar { display: none; }
.filter-tab {
@@ -200,16 +176,16 @@ const pullIndicatorStyle = () => ({
border-radius: 999px;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
background: #141414;
border: 1px solid #2a2a2a;
color: #6B7280;
background: #FFFFFF;
border: 1px solid #E5E7EB;
transition: all 0.2s;
}
.filter-tab.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: var(--border-gold-soft);
color: #003D6B;
background: rgba(0, 61, 107, 0.08);
border-color: #003D6B;
}
.state {
@@ -218,7 +194,7 @@ const pullIndicatorStyle = () => ({
align-items: center;
gap: 12px;
text-align: center;
color: var(--text-muted);
color: #6B7280;
padding: 56px 20px;
font-weight: 600;
font-size: 14px;
@@ -241,7 +217,7 @@ const pullIndicatorStyle = () => ({
.end-hint {
text-align: center;
font-size: 12px;
color: #555;
color: #6B7280;
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;

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(--odds-accent);
}
.teams-grid {

View File

@@ -328,7 +328,7 @@ function back() {
.back-btn {
background: none;
color: var(--text-muted);
color: #6B7280;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
@@ -338,15 +338,15 @@ function back() {
.page-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
color: #003D6B;
letter-spacing: 0.04em;
}
.avatar-card,
.form-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #FFFFFF;
border: 1px solid #E5E7EB;
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 #E5E7EB;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #2a2210, #141008);
background: #E8EDF2;
}
.avatar-img {
@@ -379,9 +379,9 @@ function back() {
.avatar-change-btn {
padding: 7px 18px;
border-radius: 999px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
color: var(--primary-light);
border: 1px solid #003D6B;
background: rgba(0, 61, 107, 0.04);
color: #003D6B;
font-size: 13px;
font-weight: 700;
}
@@ -396,12 +396,12 @@ function back() {
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
color: #6B7280;
}
.section-divider {
height: 1px;
background: var(--border);
background: #E5E7EB;
margin: 6px 0 12px;
}
@@ -412,13 +412,13 @@ function back() {
justify-content: space-between;
padding: 4px 0 12px;
background: none;
color: var(--text);
color: #1A1A2E;
font-size: 14px;
font-weight: 700;
}
.chevron {
color: var(--text-muted);
color: #6B7280;
font-size: 20px;
line-height: 1;
transition: transform 0.15s ease;
@@ -442,7 +442,7 @@ function back() {
.field-hint {
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
margin: 6px 0 0;
line-height: 1.45;
}
@@ -469,10 +469,10 @@ function back() {
min-width: 48px;
padding: 0 10px;
border: none;
border-left: 1px solid var(--border);
border-left: 1px solid #E5E7EB;
border-radius: 0 6px 6px 0;
background: rgba(212, 175, 55, 0.06);
color: var(--primary-light);
background: #F5F7FA;
color: #003D6B;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
@@ -491,7 +491,7 @@ function back() {
label {
display: block;
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
margin-bottom: 6px;
}
@@ -504,28 +504,28 @@ label {
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text);
border: 1px solid #E5E7EB;
background: #FFFFFF;
color: #1A1A2E;
transition: border-color 0.2s;
}
.field-input:focus {
outline: none;
border-color: var(--border-gold-soft);
box-shadow: 0 0 0 1px rgba(212, 175, 55, 0.12);
border-color: #0066CC;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.12);
}
.field-input:-webkit-autofill,
.field-input:-webkit-autofill:hover,
.field-input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--text);
box-shadow: 0 0 0 1000px #0a0a0a inset;
border: 1px solid var(--border);
-webkit-text-fill-color: #1A1A2E;
box-shadow: 0 0 0 1000px #FFFFFF inset;
border: 1px solid #E5E7EB;
}
.field-input::placeholder {
color: #555;
color: rgba(107, 114, 128, 0.6);
}
.readonly {
@@ -555,10 +555,10 @@ label {
}
.msg.ok {
color: var(--primary-light);
color: #059669;
}
.msg.err {
color: var(--danger);
color: #DC2626;
}
</style>

View File

@@ -8,18 +8,15 @@ import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useInboxFeature } from '../composables/useInboxFeature';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
import walletBg from '../assets/images/wallet-bg.webp';
const { t, locale } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
const { hubRoute } = useInboxFeature();
const loading = ref(true);
const error = ref(false);
@@ -125,9 +122,6 @@ const balanceAmountClass = computed(() => {
<template v-else>
<div class="wallet-banner">
<img class="wallet-banner-img" :src="walletBg" alt="" />
<div class="wallet-banner-scrim" aria-hidden="true" />
<div class="card-overlay">
<div class="bank-card-top">
<div class="bank-card-top-left">
@@ -144,72 +138,55 @@ const balanceAmountClass = computed(() => {
</div>
<div class="bank-card-balance">
<span class="bank-card-label">当前余额</span>
<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">持卡人</span>
<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">累计返水</span>
<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">未结算</span>
<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="/wallet/detail" class="settings-cell settings-cell--gold-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">
<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" />
</svg>
<span class="cell-label">{{ t('wallet.view_all') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/profile/cashbacks" class="settings-cell settings-cell--gold-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>
<RouterLink :to="hubRoute" class="settings-cell settings-cell--gold-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">
<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11A2.5 2.5 0 0 1 17.5 20H6.5A2.5 2.5 0 0 1 4 17.5v-11Z" />
<path d="m4 7 8 5.5L20 7" />
</svg>
<span class="cell-label">{{ t('messages.title') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/wallet/recharge/history" class="settings-cell settings-cell--gold-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">
<path d="M12 8v8M8 12h8" />
<circle cx="12" cy="12" r="8" />
</svg>
<span class="cell-label">{{ t('recharge.history_title') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<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">
@@ -287,7 +264,7 @@ const balanceAmountClass = computed(() => {
}
.profile-page {
padding: 8px 0 12px;
padding: 6px 0 10px;
}
.loading-state {
@@ -307,7 +284,7 @@ const balanceAmountClass = computed(() => {
}
.error-text {
color: var(--text-muted);
color: #6B7280;
font-size: 14px;
font-weight: 600;
}
@@ -315,75 +292,36 @@ const balanceAmountClass = computed(() => {
.retry-btn {
padding: 8px 24px;
border-radius: 6px;
border: 1px solid var(--primary);
border: 1px solid #003D6B;
background: transparent;
color: var(--primary-light);
color: #005B9F;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.retry-btn:active {
background: rgba(200, 168, 78, 0.15);
background: rgba(0, 61, 107, 0.08);
}
.wallet-banner {
position: relative;
width: 100%;
margin-bottom: 12px;
aspect-ratio: 2 / 1;
border-radius: 16px;
margin-bottom: 10px;
aspect-ratio: 1.75 / 1;
border-radius: 12px;
overflow: hidden;
box-shadow:
0 3px 10px rgba(0, 0, 0, 0.28),
0 1px 3px rgba(0, 0, 0, 0.18);
}
.wallet-banner::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
box-shadow: inset 0 0 14px rgba(0, 0, 0, 0.22);
pointer-events: none;
}
.wallet-banner-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-position: center center;
transform: scale(1.12);
transform-origin: center center;
filter: brightness(0.86);
pointer-events: none;
user-select: none;
}
.wallet-banner-scrim {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0.18) 42%,
rgba(0, 0, 0, 0.46) 100%
);
box-shadow: 0 4px 16px rgba(0, 61, 107, 0.18);
background: linear-gradient(135deg, #003D6B 0%, #005B9F 52%, #002847 100%);
}
.card-overlay {
position: absolute;
inset: 0;
z-index: 3;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
padding: 14px 16px 12px;
height: 100%;
padding: 10px 14px 10px;
line-height: 1.3;
-webkit-font-smoothing: antialiased;
}
@@ -404,8 +342,8 @@ const balanceAmountClass = computed(() => {
}
.brand-logo {
width: 22px;
height: 22px;
width: 18px;
height: 18px;
flex-shrink: 0;
object-fit: contain;
}
@@ -420,17 +358,16 @@ const balanceAmountClass = computed(() => {
.brand-name {
font-size: 11px;
font-weight: 700;
color: #fff;
color: #FFFFFF;
letter-spacing: 0.14em;
line-height: 1;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
.bank-card-type {
font-size: 8px;
font-weight: 800;
letter-spacing: 0.14em;
color: rgba(212, 175, 55, 0.65);
color: rgba(255, 255, 255, 0.6);
font-style: italic;
line-height: 1;
}
@@ -441,9 +378,8 @@ const balanceAmountClass = computed(() => {
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.82);
color: rgba(255, 255, 255, 0.75);
line-height: 1.3;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
}
.recharge-btn {
@@ -453,20 +389,19 @@ const balanceAmountClass = computed(() => {
gap: 3px;
padding: 5px 10px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(212, 175, 55, 0.35);
color: var(--primary-light);
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;
backdrop-filter: blur(6px);
transition: background 0.15s ease;
}
.recharge-btn:active {
background: rgba(212, 175, 55, 0.15);
background: rgba(255, 255, 255, 0.25);
}
.recharge-icon {
@@ -481,8 +416,8 @@ const balanceAmountClass = computed(() => {
flex-direction: column;
justify-content: center;
min-height: 0;
padding: 2px 0;
gap: 5px;
padding: 1px 0;
gap: 3px;
}
.bank-card-number {
@@ -491,39 +426,38 @@ const balanceAmountClass = computed(() => {
font-weight: 700;
font-style: normal;
font-variant-numeric: tabular-nums;
color: var(--primary-light);
color: #FFFFFF;
line-height: 1.1;
letter-spacing: 0.04em;
white-space: nowrap;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}
.balance-amount {
margin: 0;
}
.balance-amount--xl { font-size: clamp(24px, 6.2vw, 30px); }
.balance-amount--lg { font-size: clamp(21px, 5.6vw, 26px); }
.balance-amount--md { font-size: clamp(18px, 4.9vw, 22px); }
.balance-amount--sm { font-size: clamp(16px, 4.2vw, 19px); }
.balance-amount--xs { font-size: clamp(14px, 3.6vw, 16px); }
.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: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
gap: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.bank-card-field {
display: flex;
flex-direction: column;
gap: 4px;
gap: 3px;
min-width: 0;
}
@@ -533,11 +467,10 @@ const balanceAmountClass = computed(() => {
.bank-card-holder {
font-size: 12px;
font-weight: 700;
color: #fff;
color: #FFFFFF;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.2;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
word-break: break-all;
}
@@ -545,16 +478,93 @@ const balanceAmountClass = computed(() => {
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: #fff;
color: #FFFFFF;
line-height: 1.2;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
}
.quick-actions {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 10px;
}
.qa-item {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 4px 10px;
border-radius: 10px;
background: #FFFFFF;
border: 1px solid #CBD5E1;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
text-decoration: none;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
}
.qa-item:active {
transform: scale(0.96);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.qa-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.qa-icon--wallet {
background: rgba(0, 61, 107, 0.1);
color: #003D6B;
}
.qa-icon--recharge {
background: rgba(5, 150, 105, 0.1);
color: #059669;
}
.qa-icon--cashback {
background: rgba(248, 151, 31, 0.1);
color: #E8870A;
}
.qa-icon--history {
background: rgba(99, 102, 241, 0.1);
color: #6366F1;
}
.qa-icon--inbox {
background: rgba(14, 165, 233, 0.1);
color: #0EA5E9;
}
.qa-label {
font-size: 10px;
font-weight: 600;
color: #1A1A2E;
text-align: center;
line-height: 1.2;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.4em;
max-width: 100%;
word-break: break-word;
}
.settings-group {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
}
@@ -562,16 +572,16 @@ const balanceAmountClass = computed(() => {
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;
color: #1A1A2E;
font-size: 13px;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid #E5E7EB;
}
.settings-cell:last-child {
@@ -579,41 +589,15 @@ const balanceAmountClass = computed(() => {
}
.settings-cell:active {
background: rgba(255, 255, 255, 0.03);
}
.settings-cell--gold-entry {
position: relative;
min-height: 50px;
border-color: rgba(212, 175, 55, 0.18);
border-bottom-color: rgba(212, 175, 55, 0.18);
background:
linear-gradient(90deg, rgba(212, 175, 55, 0.08), rgba(20, 20, 20, 0.65) 46%, rgba(212, 175, 55, 0.04));
box-shadow:
inset 1px 0 0 rgba(212, 175, 55, 0.22),
inset 0 1px 0 rgba(255, 244, 200, 0.05);
}
.settings-cell--gold-entry:active {
background:
linear-gradient(90deg, rgba(212, 175, 55, 0.12), rgba(20, 20, 20, 0.74) 46%, rgba(212, 175, 55, 0.06));
}
.settings-cell--gold-entry .cell-label {
color: #f4dc8b;
font-weight: 800;
}
.settings-cell--gold-entry .cell-chevron {
color: rgba(240, 216, 117, 0.78);
background: #F5F7FA;
}
.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;
}
@@ -628,11 +612,7 @@ const balanceAmountClass = computed(() => {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-muted);
}
.settings-cell--gold-entry .cell-icon {
color: var(--primary-light);
color: #6B7280;
}
.cell-head {
@@ -642,11 +622,11 @@ const balanceAmountClass = computed(() => {
}
.cell-label {
color: var(--text);
color: #1A1A2E;
}
.cell-chevron {
color: var(--text-muted);
color: #6B7280;
font-size: 20px;
line-height: 1;
font-weight: 300;
@@ -664,8 +644,8 @@ const balanceAmountClass = computed(() => {
}
.rules-cell .rules-body {
padding: 0 16px 14px;
border-bottom: 1px solid var(--border);
padding: 0 14px 12px;
border-bottom: 1px solid #E5E7EB;
}
.lang-segment {
@@ -679,43 +659,43 @@ const balanceAmountClass = computed(() => {
align-items: center;
justify-content: center;
gap: 4px;
min-height: 34px;
padding: 0 6px;
min-height: 30px;
padding: 0 5px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text-muted);
border: 1px solid #E5E7EB;
background: #FFFFFF;
color: #6B7280;
font-size: 12px;
font-weight: 700;
}
.lang-opt.active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: #003D6B;
color: #003D6B;
background: rgba(0, 61, 107, 0.06);
}
.logout-btn {
width: 100%;
margin-top: 12px;
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;
margin-top: 10px;
min-height: 40px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid #DC2626;
background: #FFFFFF;
color: #DC2626;
font-size: 13px;
font-weight: 700;
}
.logout-btn:active {
background: rgba(255, 69, 58, 0.08);
background: rgba(220, 38, 38, 0.06);
}
.rules-body {
font-size: 12px;
line-height: 1.55;
color: var(--text-muted);
color: #6B7280;
}
.rules-body p {

View File

@@ -1,22 +1,22 @@
<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 { findDepositOrderById, enrichDepositOrder } from '../utils/depositOrderLookup';
import {
auditActionTone,
auditActorSecondary,
formatDepositAuditRemark,
shouldShowAuditRejectInTimeline,
} from '../utils/depositAuditDisplay';
import { useDepositNotifications } from '../composables/useDepositNotifications';
const router = useRouter();
const route = useRoute();
const { t, locale } = useI18n();
const { trackPendingOrder, pollOnce } = useDepositNotifications();
interface DepositAuditLog {
id: string;
@@ -68,10 +68,6 @@ async function fetchOrders(p = 1) {
if (p === 1) {
items.value = newItems;
for (const order of newItems) {
if (order.status === 'PENDING') trackPendingOrder(order.id);
}
void pollOnce();
} else {
items.value = [...items.value, ...newItems];
}
@@ -91,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) {
@@ -103,6 +99,10 @@ onMounted(() => {
if (sentinel.value) observer.observe(sentinel.value);
});
watch(() => route.query.orderId, () => {
void openDetailFromQuery();
});
onUnmounted(() => {
observer?.disconnect();
});
@@ -127,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) 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,
@@ -322,10 +332,10 @@ function auditStepCount(order: DepositOrder) {
<Teleport to="body">
<div v-if="selectedOrder" class="detail-overlay" @click.self="closeDetail">
<div class="detail-modal" role="dialog" aria-modal="true" :aria-label="t('recharge.audit_title')">
<div class="detail-modal" role="dialog" aria-modal="true" :aria-label="t('recharge.order_detail')">
<button type="button" class="detail-close" :aria-label="t('common.close')" @click="closeDetail"></button>
<h3 class="detail-title">{{ t('recharge.audit_title') }}</h3>
<h3 class="detail-title">{{ t('recharge.order_detail') }}</h3>
<div class="detail-summary">
<div class="detail-summary-head">
@@ -415,25 +425,26 @@ function auditStepCount(order: DepositOrder) {
</template>
<style scoped>
.recharge-history-page { padding: 0 0 24px; }
.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; }
.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; }
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; color: #1A1A2E; }
.back-btn { background: none; border: none; color: #003D6B; font-size: 24px; cursor: pointer; padding: 0 8px; }
.recharge-btn { background: none; border: none; color: #003D6B; font-size: 13px; cursor: pointer; font-weight: 600; }
.state { display: flex; justify-content: center; padding: 48px; }
.empty { text-align: center; color: #666; padding: 48px 16px; font-weight: 600; }
.empty { text-align: center; color: #6B7280; 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: linear-gradient(135deg, #1a1810 0%, #1f1b0e 40%, #16140c 100%);
border: 1px solid rgba(212, 175, 55, 0.2);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 12px;
padding: 16px;
position: relative;
overflow: hidden;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.order-card:active {
opacity: 0.92;
@@ -443,69 +454,66 @@ function auditStepCount(order: DepositOrder) {
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.6), transparent);
background: linear-gradient(90deg, transparent, #003D6B, transparent);
}
.order-card.rejected {
background: #141414;
border-color: rgba(245, 108, 108, 0.22);
background: #FFFFFF;
border-color: rgba(220, 38, 38, 0.25);
}
.order-card.rejected::before {
background: linear-gradient(90deg, transparent, rgba(245, 108, 108, 0.35), transparent);
background: linear-gradient(90deg, transparent, #DC2626, transparent);
}
.order-card.rejected .order-amount {
background: none;
-webkit-background-clip: unset;
background-clip: unset;
color: #bbb;
color: #6B7280;
}
.order-card.rejected .info-label {
color: #888;
color: #6B7280;
}
.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(0, 61, 107, 0.08); color: #005B9F; }
.method-badge.usdt { background: rgba(5, 150, 105, 0.08); color: #059669; }
.status-badge { font-size: 12px; font-weight: 700; }
.status-pending { color: #e6a23c; }
.status-approved { color: #67c23a; }
.status-rejected { color: #f56c6c; }
.status-pending { color: #F8971F; }
.status-approved { color: #059669; }
.status-rejected { color: #DC2626; }
.order-body { }
.order-amount {
font-size: 22px;
font-weight: 900;
margin-bottom: 4px;
background: linear-gradient(135deg, #f0d060, #d4a830);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
color: #003D6B;
}
.approved-amount { font-size: 12px; color: #67c23a; margin-bottom: 6px; font-weight: 600; }
.approved-amount { font-size: 12px; color: #059669; margin-bottom: 6px; font-weight: 600; }
.order-info-row { margin-bottom: 8px; }
.info-label { font-size: 13px; color: rgba(212, 175, 55, 0.7); font-weight: 600; }
.info-label { font-size: 13px; color: #003D6B; 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: #888; }
.time-value { font-size: 11px; color: #aaa; font-variant-numeric: tabular-nums; }
.time-label { font-size: 11px; color: #6B7280; }
.time-value { font-size: 11px; color: #1A1A2E; font-variant-numeric: tabular-nums; }
.order-remark {
font-size: 12px;
color: rgba(212, 175, 55, 0.8);
background: rgba(212, 175, 55, 0.06);
color: #003D6B;
background: rgba(0, 61, 107, 0.04);
padding: 6px 10px;
border-radius: 6px;
margin-top: 6px;
border-left: 2px solid rgba(212, 175, 55, 0.3);
border-left: 2px solid #003D6B;
line-height: 1.45;
word-break: break-word;
}
.reject-reason {
margin-top: 8px;
font-size: 12px;
color: #c07070;
color: #DC2626;
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(220, 38, 38, 0.18);
line-height: 1.45;
word-break: break-word;
}
@@ -516,16 +524,16 @@ function auditStepCount(order: DepositOrder) {
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(212, 175, 55, 0.12);
border-top: 1px solid #E5E7EB;
}
.card-detail-summary {
font-size: 11px;
color: rgba(212, 175, 55, 0.65);
color: #6B7280;
font-weight: 600;
}
.card-detail-link {
font-size: 11px;
color: var(--primary-light);
color: #003D6B;
font-weight: 700;
white-space: nowrap;
}
@@ -538,7 +546,7 @@ function auditStepCount(order: DepositOrder) {
background: none;
text-align: right;
font-size: 11px;
color: var(--primary-light);
color: #003D6B;
font-weight: 700;
cursor: pointer;
}
@@ -556,7 +564,7 @@ function auditStepCount(order: DepositOrder) {
.end-hint {
text-align: center;
font-size: 12px;
color: #555;
color: #6B7280;
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;
@@ -566,7 +574,7 @@ function auditStepCount(order: DepositOrder) {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.48);
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: flex-end;
justify-content: center;
@@ -579,12 +587,12 @@ function auditStepCount(order: DepositOrder) {
max-width: 480px;
max-height: min(88vh, 720px);
overflow-y: auto;
background: #141414;
border: 1px solid #2a2a2a;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-bottom: none;
border-radius: 14px 14px 0 0;
padding: 16px 16px calc(14px + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.28);
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12);
}
.detail-close {
@@ -596,7 +604,7 @@ function auditStepCount(order: DepositOrder) {
border: none;
border-radius: 50%;
background: transparent;
color: #777;
color: #6B7280;
font-size: 13px;
cursor: pointer;
line-height: 1;
@@ -606,15 +614,15 @@ function auditStepCount(order: DepositOrder) {
margin: 0 28px 12px 0;
font-size: 15px;
font-weight: 600;
color: #e8e8e8;
color: #1A1A2E;
}
.detail-summary {
margin-bottom: 14px;
padding: 12px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid #222;
background: #F5F7FA;
border: 1px solid #E5E7EB;
}
.detail-summary-head {
@@ -628,13 +636,13 @@ function auditStepCount(order: DepositOrder) {
font-size: 22px;
font-weight: 700;
margin-bottom: 4px;
color: var(--primary-light, #d4af37);
color: #003D6B;
letter-spacing: -0.02em;
}
.detail-method-name {
font-size: 12px;
color: #999;
color: #6B7280;
font-weight: 500;
margin-bottom: 8px;
}
@@ -649,13 +657,13 @@ function auditStepCount(order: DepositOrder) {
.detail-label {
font-size: 12px;
color: #888;
color: #6B7280;
flex-shrink: 0;
}
.detail-value {
font-size: 12px;
color: #ccc;
color: #1A1A2E;
text-align: right;
word-break: break-word;
}
@@ -663,12 +671,12 @@ function auditStepCount(order: DepositOrder) {
.detail-summary .order-remark {
margin-top: 8px;
font-size: 11px;
color: #999;
color: #6B7280;
background: transparent;
padding: 6px 0 0;
border-radius: 0;
border: none;
border-top: 1px solid #222;
border-top: 1px solid #E5E7EB;
line-height: 1.45;
word-break: break-word;
}
@@ -676,12 +684,12 @@ function auditStepCount(order: DepositOrder) {
.detail-summary .reject-reason {
margin-top: 8px;
font-size: 11px;
color: #b08888;
color: #DC2626;
background: transparent;
padding: 6px 0 0;
border-radius: 0;
border: none;
border-top: 1px solid rgba(245, 108, 108, 0.15);
border-top: 1px solid rgba(220, 38, 38, 0.15);
line-height: 1.45;
word-break: break-word;
}
@@ -694,14 +702,14 @@ function auditStepCount(order: DepositOrder) {
.detail-audit {
margin-top: 2px;
padding-top: 12px;
border-top: 1px solid #222;
border-top: 1px solid #E5E7EB;
}
.detail-audit-title {
margin: 0 0 10px;
font-size: 12px;
font-weight: 600;
color: #888;
color: #6B7280;
letter-spacing: 0.02em;
}
@@ -729,7 +737,7 @@ function auditStepCount(order: DepositOrder) {
width: 5px;
height: 5px;
border-radius: 50%;
background: #555;
background: #E5E7EB;
flex-shrink: 0;
}
@@ -738,7 +746,7 @@ function auditStepCount(order: DepositOrder) {
width: 1px;
min-height: 10px;
margin: 3px 0;
background: #2a2a2a;
background: #E5E7EB;
}
.audit-step-body {
@@ -761,13 +769,13 @@ function auditStepCount(order: DepositOrder) {
.audit-step-title {
font-size: 12px;
font-weight: 600;
color: #ccc;
color: #1A1A2E;
line-height: 1.35;
}
.audit-step-time {
font-size: 10px;
color: #666;
color: #6B7280;
font-variant-numeric: tabular-nums;
white-space: nowrap;
flex-shrink: 0;
@@ -776,7 +784,7 @@ function auditStepCount(order: DepositOrder) {
.audit-step-actor {
margin: 2px 0 0;
font-size: 11px;
color: #999;
color: #6B7280;
line-height: 1.4;
}
@@ -784,14 +792,14 @@ function auditStepCount(order: DepositOrder) {
margin: 3px 0 0;
font-size: 11px;
font-weight: 500;
color: #7eb87a;
color: #059669;
line-height: 1.4;
}
.audit-step-note {
margin: 4px 0 0;
font-size: 11px;
color: #888;
color: #6B7280;
line-height: 1.45;
word-break: break-word;
}
@@ -809,35 +817,35 @@ function auditStepCount(order: DepositOrder) {
.audit-step-box--reject {
background: transparent;
border: 1px solid rgba(245, 108, 108, 0.22);
border: 1px solid rgba(220, 38, 38, 0.22);
}
.audit-step-box-label {
font-size: 10px;
font-weight: 500;
color: #c07070;
color: #DC2626;
letter-spacing: 0.01em;
}
.audit-step-box-text {
font-size: 11px;
color: #b08888;
color: #DC2626;
}
.audit-step--submitted .audit-dot {
background: #c9a227;
background: #003D6B;
}
.audit-step--approved .audit-dot {
background: #5fad5a;
background: #059669;
}
.audit-step--rejected .audit-dot {
background: #d06060;
background: #DC2626;
}
.audit-step--revoked .audit-dot {
background: #777;
background: #6B7280;
}
.audit-step--reopened .audit-dot {
background: #c9a227;
background: #003D6B;
}
.modal-reapply-btn {

View File

@@ -127,8 +127,7 @@ async function compressScreenshot(file: File): Promise<File> {
throw new Error('COMPRESS_FAILED');
}
// 重命名文件后缀为 .webp
const name = file.name.replace(/\.[^/.]+$/, "") + '.webp';
const name = file.name.replace(/\.[^/.]+$/, '') + '.webp';
return new File([compressed], name, {
type: 'image/webp',
lastModified: Date.now(),
@@ -380,15 +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: #1A1A2E; }
.back-btn { background: none; border: none; color: #003D6B; font-size: 22px; cursor: pointer; padding: 0 6px; }
.history-btn { background: none; border: none; color: #003D6B; font-size: 12px; cursor: pointer; font-weight: 600; }
.state { display: flex; justify-content: center; padding: 48px; }
@@ -398,24 +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: #DC2626;
background: rgba(220, 38, 38, 0.06);
border: 1px solid rgba(220, 38, 38, 0.2);
}
.type-tabs {
display: flex; margin-bottom: 12px;
border-radius: 6px; overflow: hidden;
border: 1px solid rgba(212, 175, 55, 0.2);
border: 1px solid #E5E7EB;
}
.tab {
flex: 1; padding: 8px; border: none;
background: rgba(20, 20, 20, 0.8);
color: var(--text-muted); font-weight: 700; font-size: 13px;
background: #FFFFFF;
color: #6B7280; font-weight: 700; font-size: 13px;
cursor: pointer; transition: all 0.2s;
}
.tab.active {
background: var(--primary-light); color: #000;
background: #003D6B; color: #FFFFFF;
}
.methods-list {
@@ -423,19 +422,19 @@ onMounted(fetchMethods);
}
.method-pill {
display: flex; flex-direction: column; gap: 1px;
background: rgba(20, 20, 20, 0.8);
border: 1px solid var(--border);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 6px; padding: 6px 12px;
text-align: left; cursor: pointer;
transition: border-color 0.15s;
}
.method-pill.selected {
border-color: var(--primary-light);
background: rgba(212, 175, 55, 0.06);
border-color: #0066CC;
background: rgba(0, 102, 204, 0.04);
}
.pill-name { font-weight: 700; font-size: 12px; color: var(--text); }
.pill-sub { font-size: 10px; color: var(--text-muted); }
.empty-methods { text-align: center; color: var(--text-muted); padding: 20px; font-size: 12px; }
.pill-name { font-weight: 700; font-size: 12px; color: #1A1A2E; }
.pill-sub { font-size: 10px; color: #6B7280; }
.empty-methods { text-align: center; color: #6B7280; padding: 20px; font-size: 12px; }
.bank-card-wrap {
margin-bottom: 16px;
@@ -451,15 +450,9 @@ onMounted(fetchMethods);
flex-direction: column;
justify-content: space-between;
overflow: hidden;
background:
radial-gradient(ellipse 80% 60% at 100% 0%, rgba(212, 175, 55, 0.22), transparent 55%),
radial-gradient(ellipse 50% 50% at 0% 100%, rgba(212, 175, 55, 0.1), transparent 50%),
linear-gradient(135deg, #2a2218 0%, #1a1610 35%, #12100c 70%, #0a0908 100%);
border: 1px solid rgba(212, 175, 55, 0.35);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.55),
0 2px 8px rgba(212, 175, 55, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
background: linear-gradient(135deg, #003D6B 0%, #005B9F 50%, #003D6B 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.bank-card-shine {
@@ -468,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;
@@ -479,7 +472,7 @@ onMounted(fetchMethods);
.bank-card-deco {
position: absolute;
border-radius: 50%;
border: 1px solid rgba(212, 175, 55, 0.12);
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
@@ -488,7 +481,7 @@ onMounted(fetchMethods);
height: 180px;
right: -60px;
bottom: -80px;
background: radial-gradient(circle, rgba(212, 175, 55, 0.08), transparent 70%);
background: radial-gradient(circle, rgba(255, 255, 255, 0.06), transparent 70%);
}
.bank-card-deco--2 {
@@ -496,7 +489,7 @@ onMounted(fetchMethods);
height: 100px;
right: 40px;
bottom: 20px;
border-color: rgba(212, 175, 55, 0.08);
border-color: rgba(255, 255, 255, 0.05);
}
.bank-card-top,
@@ -516,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;
@@ -538,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;
}
@@ -549,9 +540,9 @@ onMounted(fetchMethods);
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid rgba(212, 175, 55, 0.35);
background: rgba(0, 0, 0, 0.25);
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;
@@ -566,7 +557,7 @@ onMounted(fetchMethods);
.bank-card-copy:active {
opacity: 0.65;
background: rgba(212, 175, 55, 0.12);
background: rgba(255, 255, 255, 0.2);
}
.bank-card-bottom {
@@ -588,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;
}
@@ -605,87 +595,89 @@ onMounted(fetchMethods);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.18em;
color: rgba(212, 175, 55, 0.55);
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.method-info {
background: rgba(17, 17, 17, 0.9);
background: #FFFFFF;
border-radius: 8px; padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
}
.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 #E5E7EB;
}
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 11px; color: var(--text-muted); flex-shrink: 0; }
.info-label { font-size: 11px; color: #6B7280; flex-shrink: 0; }
.info-value {
font-size: 13px; font-weight: 600;
word-break: break-all; text-align: right;
max-width: 60%;
color: #1A1A2E;
}
.copyable {
cursor: pointer; color: var(--primary-light);
cursor: pointer; color: #0066CC;
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 #E5E7EB; }
.form-section { margin-bottom: 12px; }
.form-section label {
display: block; font-size: 11px; font-weight: 700;
color: var(--text-muted); margin-bottom: 4px;
color: #6B7280; margin-bottom: 4px;
}
.amount-input {
width: 100%; padding: 10px; background: #111;
border: 1px solid var(--border); border-radius: 6px;
color: #fff; font-size: 16px; font-weight: 700;
width: 100%; padding: 10px; background: #FFFFFF;
border: 1px solid #E5E7EB; border-radius: 6px;
color: #1A1A2E; font-size: 16px; font-weight: 700;
box-sizing: border-box;
}
.amount-input:focus { border-color: var(--primary-light); outline: none; }
.amount-input:focus { border-color: #0066CC; outline: none; box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15); }
.upload-area {
border: 1px dashed rgba(212, 175, 55, 0.3);
border: 1px dashed #0066CC;
border-radius: 6px; padding: 16px;
text-align: center; position: relative;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
background: rgba(0, 102, 204, 0.02);
}
.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); }
.upload-hint { font-size: 12px; color: #6B7280; }
.compress-hint { font-size: 12px; color: #003D6B; }
.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: linear-gradient(135deg, #f0d060, #d4a830);
color: #000; border: none; border-radius: 6px;
background: #003D6B;
color: #FFFFFF; border: none; border-radius: 6px;
font-size: 14px; font-weight: 800;
cursor: pointer; margin-top: 8px;
box-shadow: 0 2px 8px rgba(212, 175, 55, 0.2);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
.success-state { text-align: center; padding: 40px 16px; }
.success-icon { font-size: 40px; color: #67c23a; margin-bottom: 10px; }
.success-state h3 { margin: 0 0 6px; font-size: 16px; }
.order-no { font-family: monospace; color: var(--primary-light); font-size: 13px; margin: 4px 0; }
.success-hint { font-size: 12px; color: var(--text-muted); margin-bottom: 20px; }
.success-icon { font-size: 40px; color: #059669; margin-bottom: 10px; }
.success-state h3 { margin: 0 0 6px; font-size: 16px; color: #1A1A2E; }
.order-no { font-family: monospace; color: #003D6B; font-size: 13px; margin: 4px 0; }
.success-hint { font-size: 12px; color: #6B7280; margin-bottom: 20px; }
.btn-primary {
background: linear-gradient(135deg, #f0d060, #d4a830);
color: #000; border: none; border-radius: 6px;
background: #003D6B;
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">
<div class="top-bar">
<header class="top-bar sub-toolbar">
<button class="back-btn" type="button" @click="router.back()">&#x2039; {{ t('history.back') }}</button>
</div>
</header>
<div
class="pull-indicator"
@@ -172,7 +172,7 @@ const pullIndicatorStyle = () => ({
</div>
<template v-else>
<WalletStatsPanel :items="items" :cashback-total="cashbackTotal" />
<WalletStatsPanel class="stats-panel" :items="items" :cashback-total="cashbackTotal" />
<div class="filter-tabs">
<button
@@ -195,17 +195,15 @@ const pullIndicatorStyle = () => ({
class="tx-row"
@click="goDetail(tx)"
>
<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>
<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">
<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>
@@ -233,6 +231,10 @@ const pullIndicatorStyle = () => ({
padding-bottom: 24px;
}
.stats-panel {
margin-bottom: var(--space-section);
}
.top-bar {
margin-bottom: 4px;
}
@@ -240,9 +242,9 @@ const pullIndicatorStyle = () => ({
.back-btn {
background: none;
border: none;
color: var(--primary-light, #d4af37);
color: var(--text-muted);
font-size: 15px;
font-weight: 700;
font-weight: 500;
padding: 4px 0 8px;
cursor: pointer;
display: flex;
@@ -260,8 +262,8 @@ const pullIndicatorStyle = () => ({
.filter-tabs {
display: flex;
gap: 6px;
margin-bottom: 12px;
gap: 8px;
margin-bottom: var(--space-section);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
@@ -271,20 +273,21 @@ const pullIndicatorStyle = () => ({
.filter-tab {
flex-shrink: 0;
padding: 7px 14px;
padding: 8px 16px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
font-weight: 600;
color: var(--text-muted);
background: #141414;
border: 1px solid #2a2a2a;
transition: all 0.2s;
background: var(--bg-card);
border: 1px solid var(--border);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.filter-tab.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: var(--border-gold-soft);
color: var(--text);
background: var(--bg-elevated);
border-color: var(--border-strong);
font-weight: 600;
}
.state {
@@ -305,14 +308,21 @@ 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: block;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
text-align: left;
font-size: 14px;
padding: 12px 16px;
padding: 12px 14px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
@@ -324,21 +334,22 @@ const pullIndicatorStyle = () => ({
}
.tx-row:active {
background: rgba(255, 255, 255, 0.03);
background: var(--bg-hover);
}
.tx-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.tx-text {
.tx-col-left {
display: flex;
flex-direction: column;
gap: 2px;
gap: 3px;
min-width: 0;
flex: 1;
}
.tx-col-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.tx-summary {
@@ -350,19 +361,12 @@ const pullIndicatorStyle = () => ({
text-overflow: ellipsis;
}
.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(--primary-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: #555; font-weight: 700; line-height: 1; }
.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; }
.sentinel {
height: 1px;
@@ -377,7 +381,7 @@ const pullIndicatorStyle = () => ({
.end-hint {
text-align: center;
font-size: 12px;
color: #555;
color: var(--text-muted);
font-weight: 600;
padding: 16px 0 4px;
letter-spacing: 0.03em;

View File

@@ -138,9 +138,9 @@ function goCashbackDetail() {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<div class="top-bar">
<header class="top-bar sub-toolbar">
<button class="back-btn" type="button" @click="router.back()"> {{ t('history.back') }}</button>
</div>
</header>
<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, #d4af37);
color: var(--primary-light);
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
@@ -248,13 +248,13 @@ function goCashbackDetail() {
.state {
text-align: center;
padding: 60px 20px;
color: #666;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
}
.hero {
border-radius: 14px;
border-radius: var(--radius);
padding: 20px;
display: flex;
flex-direction: column;
@@ -262,23 +262,23 @@ function goCashbackDetail() {
gap: 6px;
margin-bottom: 12px;
text-align: center;
background: #141414;
border: 1px solid #222;
background: var(--bg-card);
border: 1px solid var(--border);
}
.hero.pos {
border-color: rgba(212, 175, 55, 0.25);
background: linear-gradient(135deg, rgba(212, 175, 55, 0.1), rgba(212, 175, 55, 0.03));
border-color: rgba(46, 204, 113, 0.35);
background: linear-gradient(135deg, rgba(46, 204, 113, 0.16), rgba(46, 204, 113, 0.05));
}
.hero.neg {
border-color: rgba(224, 80, 80, 0.2);
background: linear-gradient(135deg, rgba(224, 80, 80, 0.1), rgba(224, 80, 80, 0.03));
border-color: rgba(0, 102, 204, 0.25);
background: linear-gradient(135deg, rgba(0, 102, 204, 0.08), rgba(0, 102, 204, 0.03));
}
.hero.zero {
border-color: rgba(255, 255, 255, 0.08);
background: #141414;
border-color: var(--border);
background: var(--bg-elevated);
}
.hero-type {
@@ -286,13 +286,13 @@ function goCashbackDetail() {
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #888;
color: var(--text-muted);
}
.hero-summary {
font-size: 13px;
font-weight: 600;
color: #bbb;
color: var(--text);
max-width: 100%;
word-break: break-all;
}
@@ -303,28 +303,28 @@ function goCashbackDetail() {
line-height: 1.1;
}
.hero.pos .hero-amount { color: var(--primary-light); }
.hero.neg .hero-amount { color: var(--danger); }
.hero.zero .hero-amount { color: var(--text-muted, #888); }
.hero.pos .hero-amount { color: var(--success); }
.hero.neg .hero-amount { color: var(--primary); }
.hero.zero .hero-amount { color: var(--text-muted); }
.hero-time {
font-size: 11px;
color: #555;
color: var(--neutral);
font-weight: 600;
}
.section {
background: #141414;
border: 1px solid #222;
border-radius: 12px;
padding: 14px 16px;
margin-bottom: 10px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--space-card) 16px;
margin-bottom: var(--space-section);
}
.section-title {
font-size: 12px;
font-weight: 800;
color: #888;
color: var(--text-muted);
letter-spacing: 0.04em;
margin-bottom: 10px;
}
@@ -341,19 +341,19 @@ function goCashbackDetail() {
align-items: flex-start;
gap: 12px;
font-size: 13px;
color: #aaa;
color: var(--text-muted);
}
.sum-row span:last-child {
color: #f0f0f0;
color: var(--text);
font-weight: 700;
text-align: right;
word-break: break-all;
}
.pos { color: var(--primary-light) !important; }
.neg { color: var(--danger) !important; }
.zero { color: var(--text-muted, #888) !important; }
.pos { color: var(--success) !important; }
.neg { color: var(--primary) !important; }
.zero { color: var(--text-muted) !important; }
.mono {
font-family: ui-monospace, monospace;
@@ -370,9 +370,9 @@ function goCashbackDetail() {
margin-top: 12px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-gold-soft, rgba(212, 175, 55, 0.35));
background: rgba(212, 175, 55, 0.08);
color: var(--primary-light);
border: 1px solid var(--border-active);
background: var(--surface-accent);
color: var(--primary);
font-size: 13px;
font-weight: 700;
cursor: pointer;
@@ -381,7 +381,7 @@ function goCashbackDetail() {
.tx-id {
font-family: ui-monospace, monospace;
font-size: 12px;
color: #ccc;
color: var(--text-muted);
word-break: break-all;
line-height: 1.5;
}

View File

@@ -1,151 +1,225 @@
<script setup lang="ts">
import { ref, onActivated, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ref, onMounted, onActivated, computed } from 'vue';
import { useRouter, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../utils/walletTx';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
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';
const router = useRouter();
const { t, locale } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
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 profileLoading = ref(true);
const profileError = ref(false);
const rulesExpanded = ref(false);
const cashbackTotal = ref('0');
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;
async function fetchCashbackTotal() {
try {
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();
const { data } = await api.get('/player/cashbacks');
cashbackTotal.value = sumCashbackAmount(parseCashbackApiData(data.data)).toString();
} catch {
/* ignore */
} finally {
loading.value = false;
try {
const { data } = await api.get('/player/wallet/transactions/stats');
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 {
// ignore
}
}
}
async function fetchProfile() {
profileLoading.value = true;
profileError.value = false;
try {
await refreshProfile();
initFromUser(profileRaw.value?.locale);
void fetchCashbackTotal();
} catch {
profileError.value = true;
} finally {
profileLoading.value = false;
}
}
async function refreshAll() {
await fetchProfile();
}
const { pullDistance, spinning, progress } = usePullToRefresh({
onRefresh: fetchData,
onRefresh: refreshAll,
});
onMounted(fetchData);
onActivated(fetchData);
onMounted(refreshAll);
onActivated(refreshAll);
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>
<div class="wallet-page">
<div
class="pull-indicator"
:style="pullIndicatorStyle()"
>
<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 v-if="profileLoading" class="loading-state">
<GoldSpinner :size="36" :active="true" />
</div>
<div v-else-if="profileError" class="error-state">
<p class="error-text">{{ t('common.load_failed') }}</p>
<button type="button" class="retry-btn" @click="fetchProfile">{{ t('common.retry') }}</button>
</div>
<template v-else>
<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="more-link"
@click="router.push('/wallet/detail')"
>{{ 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-main">
<div class="tx-text">
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
<div class="wallet-banner">
<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>
<span :class="txAmountClass(tx.amount)">
{{ formatMoney(tx.amount, locale) }}
</span>
<RouterLink to="/wallet/recharge" class="recharge-btn">
<span class="recharge-icon">+</span>
<span>{{ t('recharge.title') }}</span>
</RouterLink>
</div>
<div class="tx-meta">
<span class="tx-time">{{ new Date(tx.createdAt).toLocaleString() }}</span>
<span class="tx-arrow">&#x203A;</span>
<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>
</button>
<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 v-if="!items.length" class="empty">
{{ t('wallet.no_records') }}
</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">
<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>
</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>
<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">
<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>
</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>
<style scoped>
.wallet-page {
padding-bottom: 24px;
}
.pull-indicator {
display: flex;
align-items: center;
@@ -154,110 +228,298 @@ const pullIndicatorStyle = () => ({
transition: height 0.15s ease;
}
.state {
.wallet-page {
padding: 6px 0 24px;
}
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
color: var(--text-muted);
padding: 56px 20px;
min-height: 60vh;
}
.error-text {
color: #6B7280;
font-size: 14px;
font-weight: 600;
font-size: 14px;
}
.loading-text {
font-size: 13px;
}
.tx-list {
margin-bottom: 0;
}
.tx-row {
display: block;
width: 100%;
text-align: left;
font-size: 14px;
padding: 8px 12px;
border: none;
border-bottom: 1px solid var(--border);
.retry-btn {
padding: 8px 24px;
border-radius: 6px;
border: 1px solid #003D6B;
background: transparent;
color: #005B9F;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.tx-row:last-child {
border-bottom: none;
.wallet-banner {
position: relative;
width: 100%;
margin-bottom: 10px;
aspect-ratio: 1.75 / 1;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 61, 107, 0.18);
background: linear-gradient(135deg, #003D6B 0%, #005B9F 52%, #002847 100%);
}
.tx-row:active {
background: rgba(255, 255, 255, 0.03);
}
.tx-main {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.tx-text {
.card-overlay {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 2px;
height: 100%;
padding: 10px 14px;
line-height: 1.3;
}
.bank-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bank-card-top-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.tx-summary {
.brand-logo {
width: 18px;
height: 18px;
object-fit: contain;
}
.bank-card-brand-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.brand-name {
font-size: 11px;
color: var(--text-muted);
font-weight: 700;
color: #fff;
letter-spacing: 0.14em;
}
.bank-card-type {
font-size: 8px;
font-weight: 800;
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.6);
font-style: italic;
}
.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);
}
.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: #fff;
font-size: 11px;
font-weight: 600;
text-decoration: none;
}
.recharge-icon {
font-size: 12px;
font-weight: 700;
}
.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;
color: #fff;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tx-meta {
.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 {
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;
justify-content: space-between;
align-items: center;
margin-top: 4px;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.tx-type { font-weight: 700; color: var(--text); }
.pos { color: var(--primary-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: #555; font-weight: 700; line-height: 1; }
.bank-card-field--center { text-align: center; }
.bank-card-field--right { text-align: right; }
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px 2px;
margin-top: -8px;
}
.top-bar .more-link.secondary {
color: #f0b90b;
}
.more-link {
background: none;
border: none;
color: var(--primary-light);
.bank-card-holder,
.bank-card-stat {
font-size: 12px;
font-weight: 600;
padding: 2px 6px;
color: #fff;
}
.bank-card-holder {
font-weight: 700;
text-transform: uppercase;
word-break: break-all;
}
.settings-group {
background: #fff;
border: 1px solid #E5E7EB;
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
}
.settings-cell {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 42px;
padding: 0 14px;
background: none;
color: #1A1A2E;
font-size: 13px;
font-weight: 600;
text-decoration: none;
border-bottom: 1px solid #E5E7EB;
}
.settings-cell:last-child {
border-bottom: none;
}
.settings-cell--stack {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 10px 14px 12px;
min-height: auto;
}
.cell-main,
.cell-head {
display: flex;
align-items: center;
gap: 10px;
}
.cell-icon {
width: 18px;
height: 18px;
color: #6B7280;
}
.cell-chevron {
color: #6B7280;
font-size: 20px;
font-weight: 300;
transition: transform 0.15s ease;
}
.cell-chevron.open {
transform: rotate(90deg);
}
.rules-toggle {
border: none;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
font-family: inherit;
}
.more-link:active {
opacity: 1;
.rules-cell .rules-body {
padding: 0 14px 12px;
border-bottom: 1px solid #E5E7EB;
font-size: 12px;
line-height: 1.55;
color: #6B7280;
}
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
.rules-body p {
margin: 0 0 8px;
}
.lang-segment {
display: flex;
gap: 6px;
}
.lang-opt {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-height: 30px;
border-radius: 6px;
border: 1px solid #E5E7EB;
background: #fff;
color: #6B7280;
font-size: 12px;
font-weight: 700;
}
.lang-opt.active {
border-color: #003D6B;
color: #003D6B;
background: rgba(0, 61, 107, 0.06);
}
.logout-btn {
width: 100%;
min-height: 40px;
border-radius: 8px;
border: 1px solid #DC2626;
background: #fff;
color: #DC2626;
font-size: 13px;
font-weight: 700;
}
</style>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useViewport } from '../composables/useViewport';
import MobileProfileView from './MobileProfileView.vue';
import DesktopProfileView from './desktop/DesktopProfileView.vue';
const { isDesktop } = useViewport();
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
const router = useRouter();
onMounted(() => {
void router.replace('/wallet');
});
</script>
<template>
<DesktopProfileView v-if="isDesktop" />
<MobileProfileView v-else />
<div />
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const route = useRoute();
const router = useRouter();
watch(
() => route.params.id,
(id) => {
if (!id) return;
router.replace({
path: '/wallet/recharge/history',
query: { orderId: String(id) },
});
},
{ immediate: true },
);
</script>
<template />

View File

@@ -9,7 +9,9 @@ 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/zt2.webp';
const props = withDefaults(defineProps<{ desktop?: boolean }>(), { desktop: false });
const { t, locale } = useI18n();
const { initFromUser } = useAppLocale();
@@ -101,11 +103,16 @@ const fieldError = () => {
</script>
<template>
<div class="login-page" :style="{ backgroundImage: `url(${loginBg})` }">
<div class="login-lang">
<div class="login-page" :class="{ 'login-page--desktop': props.desktop }" :style="props.desktop ? undefined : { backgroundImage: `url(${loginBg})` }">
<div v-if="!props.desktop" class="login-lang">
<LocaleSwitcher compact />
</div>
<form @submit.prevent="submit" class="login-form ps-gold-frame" :class="{ 'is-busy': loading }">
<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>
@@ -116,7 +123,7 @@ const fieldError = () => {
<label>{{ t('auth.username') }}</label>
<input
v-model="account"
class="field-input"
class="field-input ps-gold-input"
required
autocomplete="username"
maxlength="32"
@@ -132,7 +139,7 @@ const fieldError = () => {
<PhoneCountrySelect v-model="countryIso" />
<input
v-model="phone"
class="field-input"
class="field-input ps-gold-input"
type="tel"
required
autocomplete="tel-national"
@@ -147,7 +154,7 @@ const fieldError = () => {
<div class="inline-row">
<input
v-model="smsCode"
class="field-input"
class="field-input ps-gold-input"
inputmode="numeric"
maxlength="6"
autocomplete="one-time-code"
@@ -171,17 +178,17 @@ const fieldError = () => {
<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" />
<input v-model="password" class="field-input ps-gold-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" />
<input v-model="confirmPassword" class="field-input ps-gold-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" />
<input v-model="inviteCode" class="field-input ps-gold-input" autocomplete="off" :disabled="loading" />
</div>
<p v-if="fieldError()" class="error">{{ fieldError() }}</p>
@@ -199,13 +206,19 @@ const fieldError = () => {
</span>
<span v-else>{{ t('auth.register_btn') }}</span>
</button>
<button type="button" class="btn-skip" :disabled="loading" @click="goLogin">
<button v-if="props.desktop" type="button" class="btn-skip" :disabled="loading" @click="goLogin">
{{ t('auth.have_account') }}
</button>
</form>
<button type="button" class="btn-skip-light" @click="continueBrowsing">
{{ t('auth.continue_browsing') }}
</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>
</div>
</div>
</div>
</template>
@@ -214,7 +227,7 @@ const fieldError = () => {
position: absolute;
top: max(12px, env(safe-area-inset-top));
right: 16px;
z-index: 2;
z-index: 10;
}
.login-page {
@@ -226,21 +239,54 @@ const fieldError = () => {
flex-direction: column;
align-items: center;
justify-content: flex-end;
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
background-color: var(--tertiary);
padding: 30vh 16px max(20px, env(safe-area-inset-bottom));
background-color: var(--bg-body);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
}
.login-form {
.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-stack {
max-width: none;
width: 100%;
}
.login-stack {
position: relative;
z-index: 1;
width: 100%;
max-width: 320px;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.login-form {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.login-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0 14px 4px;
}
.login-form.is-busy {
@@ -257,8 +303,8 @@ const fieldError = () => {
justify-content: center;
gap: 10px;
border-radius: inherit;
background: rgba(8, 8, 8, 0.72);
backdrop-filter: blur(2px);
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(4px);
pointer-events: all;
}
@@ -266,7 +312,7 @@ const fieldError = () => {
margin: 0;
font-size: 13px;
font-weight: 700;
color: rgba(240, 216, 117, 0.95);
color: var(--primary);
letter-spacing: 0.04em;
}
@@ -277,11 +323,65 @@ const fieldError = () => {
gap: 8px;
}
.login-page--desktop .login-form {
gap: 10px;
padding: 0;
border: none !important;
background: transparent !important;
box-shadow: none !important;
}
.login-page--desktop .form-title {
margin: 0 0 8px;
font-size: 18px;
text-align: left;
color: var(--primary);
}
.login-page--desktop .field {
gap: 8px;
}
.login-page--desktop label {
font-size: 13px;
}
.login-page--desktop .field-input,
.login-page--desktop .btn-secondary {
height: 46px;
}
.login-page--desktop .btn-login {
margin-top: 10px;
min-height: 50px;
font-size: 15px;
}
.login-page--desktop .btn-skip {
margin-top: 6px;
padding: 12px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
}
.login-page--desktop .btn-skip:hover:not(:disabled) {
border-color: var(--border-active);
background: var(--surface-accent);
}
.login-page--desktop .error {
padding: 6px 10px;
border-radius: var(--radius-sm);
background: rgba(220, 38, 38, 0.08);
border-left: 2px solid var(--danger);
}
.form-title {
margin: 0 0 2px;
font-size: 16px;
font-weight: 800;
color: #fff;
color: var(--text);
text-align: center;
}
@@ -305,7 +405,7 @@ label {
.optional-tag {
font-size: 10px;
font-weight: 500;
color: rgba(255, 255, 255, 0.4);
color: var(--text-muted);
}
.field-input {
@@ -314,19 +414,19 @@ label {
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #0d0d0d;
background: var(--bg-input);
color: var(--text);
font-size: 14px;
font-family: inherit;
}
.field-input::placeholder {
color: rgba(255, 255, 255, 0.32);
color: rgba(0, 0, 0, 0.35);
}
.field-input:focus {
outline: none;
border-color: #555;
border-color: var(--border-gold-soft);
}
.inline-row {
@@ -346,7 +446,7 @@ label {
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #141414;
background: var(--bg-card);
color: var(--text);
font-size: 11px;
font-weight: 600;
@@ -354,6 +454,13 @@ label {
cursor: pointer;
}
.btn-secondary:not(:disabled).active,
.btn-secondary:active:not(:disabled) {
border-color: var(--primary);
background: var(--surface-accent);
color: var(--text);
}
.btn-secondary:disabled {
opacity: 0.45;
cursor: not-allowed;
@@ -377,37 +484,34 @@ label {
}
.btn-skip {
margin-top: 2px;
padding: 8px 14px;
padding: 6px 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;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
color: var(--primary-dark);
}
.btn-skip-light {
position: absolute;
bottom: calc(20px + env(safe-area-inset-bottom));
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
padding: 6px 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(--text);
}
.error {

View File

@@ -1,10 +1,24 @@
<script setup lang="ts">
import { watch } 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();
watch(
() => [isDesktop.value, route.params.transactionId] as const,
([desktop, transactionId]) => {
if (desktop && transactionId) {
router.replace({ path: '/wallet/detail', query: { transactionId: String(transactionId) } });
}
},
{ immediate: true },
);
</script>
<template>
<DesktopWalletTransactionDetailView v-if="isDesktop" />
<MobileWalletTransactionDetailView v-else />
<MobileWalletTransactionDetailView v-if="!isDesktop" />
</template>

View File

@@ -1,9 +1,17 @@
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
import { useViewport } from '../composables/useViewport';
import MobileWalletView from './MobileWalletView.vue';
import DesktopWalletView from './desktop/DesktopWalletView.vue';
const { isDesktop } = useViewport();
const DesktopWalletView = defineAsyncComponent(
() => import('./desktop/DesktopWalletView.vue'),
);
const MobileWalletView = defineAsyncComponent(
() => import('./MobileWalletView.vue'),
);
</script>
<template>
<DesktopWalletView v-if="isDesktop" />
<MobileWalletView 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(212, 175, 55, 0.08) 0%, rgba(18, 18, 18, 0.95) 42%);
background: linear-gradient(135deg, rgba(0, 102, 204, 0.08) 0%, var(--bg-card) 42%);
}
.back-btn {
@@ -147,8 +147,8 @@ onActivated(() => {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #141414;
color: var(--gold);
background: var(--bg-body);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
line-height: 1;
@@ -157,8 +157,8 @@ onActivated(() => {
}
.back-btn:hover {
border-color: var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
border-color: var(--border-active);
background: rgba(0, 102, 204, 0.08);
}
.back-icon {
@@ -201,8 +201,8 @@ onActivated(() => {
flex-shrink: 0;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid var(--border-gold-soft);
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--border-active);
background: rgba(0, 102, 204, 0.08);
font-size: 12px;
font-weight: 700;
color: var(--primary-light);
@@ -238,20 +238,20 @@ onActivated(() => {
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
background: rgba(18, 18, 18, 0.96);
background: var(--bg-card);
text-align: left;
transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
}
.announce-card:hover {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
transform: translateY(-2px);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
box-shadow: var(--shadow-md);
}
.card-cover {
aspect-ratio: 16 / 7;
background: #0a0a0a;
background: var(--bg-card);
overflow: hidden;
}
@@ -281,11 +281,11 @@ onActivated(() => {
flex-shrink: 0;
padding: 2px 7px;
border-radius: 4px;
background: rgba(212, 175, 55, 0.12);
border: 1px solid rgba(212, 175, 55, 0.22);
background: rgba(0, 102, 204, 0.12);
border: 1px solid rgba(0, 102, 204, 0.22);
font-size: 10px;
font-weight: 700;
color: var(--gold);
color: var(--primary-light);
}
.card-date {
@@ -299,7 +299,7 @@ onActivated(() => {
font-size: 15px;
font-weight: 800;
line-height: 1.45;
color: #fff;
color: var(--text);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;

View File

@@ -30,6 +30,7 @@ const isDetailRoute = computed(
.desktop-hub-page {
flex: 1;
min-height: 0;
height: 100%;
}
.hub-placeholder {

View File

@@ -1,42 +1,25 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter, RouterLink } from 'vue-router';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { formatMoney } from '../../utils/localeDisplay';
import type { BetHistoryItem } from '../../components/BetHistoryCard.vue';
const { t } = useI18n();
const { t, locale } = useI18n();
const route = useRoute();
const router = useRouter();
function goBack() {
if (window.history.state && window.history.state.back) {
if (window.history.state?.back) {
router.back();
} else {
router.push('/bets');
}
}
type BetDetail = {
betNo: string;
stake: string;
potentialPayout?: string;
status: string;
createdAt: string;
selections: Array<{
matchName?: string;
selectionName?: string;
odds?: number;
market?: string;
homeScore?: number;
awayScore?: number;
matchStatus?: string;
[key: string]: any;
}>;
[key: string]: any;
};
const detail = ref<BetDetail | null>(null);
const detail = ref<BetHistoryItem | null>(null);
const loading = ref(true);
const error = ref(false);
@@ -58,7 +41,13 @@ async function loadDetail() {
onMounted(loadDetail);
function statusClass(status: string) {
const map: Record<string, string> = { WON: 'status-won', LOST: 'status-lost', PENDING: 'status-pending', PUSH: 'status-push' };
const map: Record<string, string> = {
WON: 'status-won',
LOST: 'status-lost',
PENDING: 'status-pending',
PUSH: 'status-push',
CANCELLED: 'status-cancelled',
};
return map[status?.toUpperCase()] ?? 'status-default';
}
@@ -68,9 +57,67 @@ function statusLabel(status: string) {
LOST: t('history.filter_lost'),
PENDING: t('history.filter_pending'),
PUSH: t('history.filter_push'),
CANCELLED: t('common.cancelled'),
};
return map[status?.toUpperCase()] ?? status;
}
const SEL_TRANS: Record<string, Record<string, string>> = {
'主胜': { 'en-US': 'Home Win', 'ms-MY': 'Rumah Menang' },
'客胜': { 'en-US': 'Away Win', 'ms-MY': 'Tandang Menang' },
'和局': { 'en-US': 'Draw', 'ms-MY': 'Seri' },
'主': { 'en-US': 'Home', 'ms-MY': 'Rumah' },
'客': { 'en-US': 'Away', 'ms-MY': 'Tandang' },
'大': { 'en-US': 'Over', 'ms-MY': 'Atas' },
'小': { 'en-US': 'Under', 'ms-MY': 'Bawah' },
'单': { 'en-US': 'Odd', 'ms-MY': 'Ganjil' },
'双': { 'en-US': 'Even', 'ms-MY': 'Genap' },
'冠军': { 'en-US': 'Winner', 'ms-MY': 'Juara' },
};
function translateSel(name: string): string {
if (locale.value === 'zh-CN') return name;
const exact = SEL_TRANS[name];
if (exact) return exact[locale.value] ?? exact['en-US'] ?? name;
const sp = name.indexOf(' ');
if (sp > 0) {
const head = name.slice(0, sp);
const m = SEL_TRANS[head];
if (m) return (m[locale.value] ?? m['en-US'] ?? head) + name.slice(sp);
}
return name;
}
function legResultIcon(status?: string | null) {
switch (status?.toUpperCase()) {
case 'WON': return '✓';
case 'LOST': return '✗';
case 'PUSH': return '=';
default: return '';
}
}
function legResultClass(status?: string | null) {
switch (status?.toUpperCase()) {
case 'WON': return 'leg-won';
case 'LOST': return 'leg-lost';
case 'PUSH': return 'leg-push';
default: return 'leg-pending';
}
}
const displayLegs = computed(() => {
if (!detail.value) return [];
if (detail.value.legs?.length) return detail.value.legs;
return [{
matchTitle: detail.value.matchTitle,
marketLabel: '',
selectionName: detail.value.pickLabel,
odds: detail.value.totalOdds,
resultStatus: detail.value.status,
score: detail.value.matchScore ?? null,
}];
});
</script>
<template>
@@ -78,9 +125,9 @@ function statusLabel(status: string) {
<main class="account-main">
<div class="page-header">
<button type="button" class="back-btn" @click="goBack">
{{ t('common.back') || '返回' }}
{{ t('common.back') }}
</button>
<h1 class="page-title">{{ t('bet.detail_title') || '注单详情' }}</h1>
<h1 class="page-title">{{ t('bet.detail_title') }}</h1>
</div>
<div v-if="loading" class="loading-state">
@@ -92,42 +139,69 @@ function statusLabel(status: string) {
<button type="button" class="retry-btn" @click="loadDetail">{{ t('common.retry') }}</button>
</div>
<div v-else-if="detail" class="detail-layout">
<!-- Header info -->
<div class="detail-card header-card">
<div class="bet-no">{{ detail.betNo }}</div>
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
<div class="amount-row">
<div class="amount-item">
<span class="amount-label">{{ t('bet.stake') || '投注额' }}</span>
<span class="amount-val">{{ detail.stake }}</span>
</div>
<div class="amount-item">
<span class="amount-label">{{ t('bet.potential_payout') || '预计派彩' }}</span>
<span class="amount-val gold">{{ detail.potentialPayout || '-' }}</span>
</div>
<div class="amount-item">
<span class="amount-label">{{ t('bet.placed_at') || '下注时间' }}</span>
<span class="amount-val date">{{ new Date(detail.createdAt).toLocaleString() }}</span>
<div v-else-if="detail" class="detail-grid">
<div class="detail-left">
<div class="detail-card">
<div class="card-title">{{ t('bet.selections') }}</div>
<div v-for="(leg, idx) in displayLegs" :key="idx" class="sel-row">
<div class="sel-row-left">
<span v-if="displayLegs.length > 1" class="sel-index">{{ idx + 1 }}</span>
<div class="sel-info">
<div class="sel-match">{{ leg.matchTitle || t('bet.match') }}</div>
<div class="sel-meta">
<span v-if="leg.marketLabel" class="sel-market">{{ leg.marketLabel }}</span>
<span class="sel-name">{{ translateSel(leg.selectionName || '') }}</span>
<span v-if="leg.score?.ft" class="sel-score">{{ t('history.ft') }}: {{ leg.score.ft }}</span>
</div>
</div>
</div>
<div class="sel-row-right">
<span class="sel-odds">@{{ leg.odds ?? '-' }}</span>
<span
v-if="leg.resultStatus"
class="leg-result"
:class="legResultClass(leg.resultStatus)"
>
{{ legResultIcon(leg.resultStatus) }}
</span>
</div>
</div>
</div>
</div>
<!-- Selections -->
<div class="detail-card">
<div class="card-title">{{ t('bet.selections') || '投注选项' }}</div>
<div v-for="(sel, idx) in detail.selections" :key="idx" class="sel-row">
<div class="sel-match">{{ sel.matchName || t('bet.match') }}</div>
<div class="sel-meta">
<span class="sel-market">{{ sel.market || sel.marketName || '' }}</span>
<span class="sel-name">{{ sel.selectionName || '' }}</span>
<span class="sel-odds">@{{ sel.odds }}</span>
<div class="detail-right">
<div class="detail-card summary-card">
<div class="card-title">{{ t('history.summary') }}</div>
<div class="summary-bet-no">
<span class="summary-label">{{ t('bet.bet_no') }}</span>
<span class="summary-value mono">{{ detail.betNo }}</span>
</div>
<div class="summary-status">
<span class="status-badge" :class="statusClass(detail.status)">{{ statusLabel(detail.status) }}</span>
</div>
<div class="summary-amounts">
<div class="summary-amount-item">
<span class="summary-amount-label">{{ t('bet.stake') }}</span>
<span class="summary-amount-val">{{ formatMoney(detail.stake, locale) }}</span>
</div>
<div class="summary-amount-item">
<span class="summary-amount-label">{{ t('bet.potential_payout') }}</span>
<span class="summary-amount-val accent">{{ formatMoney(detail.potentialReturn, locale) }}</span>
</div>
<div class="summary-amount-item">
<span class="summary-amount-label">{{ t('bet.placed_at') }}</span>
<span class="summary-amount-val date">
{{ detail.placedAt ? new Date(detail.placedAt).toLocaleString() : '-' }}
</span>
</div>
</div>
</div>
<RouterLink to="/bets" class="back-to-list-btn">{{ t('common.back') }}</RouterLink>
</div>
</div>
<div v-else class="empty-state">{{ t('common.not_found') || '未找到记录' }}</div>
<div v-else class="empty-state">{{ t('common.not_found') }}</div>
</main>
</div>
</template>
@@ -142,48 +216,136 @@ function statusLabel(status: string) {
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
.loading-state { display: flex; justify-content: center; align-items: center; padding: 80px 0; }
.error-state { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 80px 20px; color: var(--text-muted); }
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--primary); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
.retry-btn { padding: 8px 24px; border-radius: 6px; border: 1px solid var(--border-active); background: transparent; color: var(--primary-light); font-size: 13px; font-weight: 700; cursor: pointer; }
.empty-state { display: flex; align-items: center; justify-content: center; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
.detail-layout { display: flex; flex-direction: column; gap: 16px; max-width: 680px; }
.detail-grid {
display: grid;
grid-template-columns: 1fr 340px;
gap: 20px;
max-width: 1100px;
}
.detail-left { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.detail-right { display: flex; flex-direction: column; gap: 16px; }
.detail-card {
background: var(--desktop-sidebar-bg);
border: 1px solid var(--desktop-border);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
}
.header-card { display: flex; flex-direction: column; gap: 12px; }
.bet-no {
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 13px;
.card-title {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
letter-spacing: 0.04em;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; align-self: flex-start; }
.status-won { background: rgba(52,199,89,0.12); color: #4cd964; }
.status-lost { background: rgba(255,69,58,0.12); color: #ff453a; }
.status-pending { background: rgba(212,175,55,0.12); color: var(--primary-light); }
.status-push { background: rgba(100,100,100,0.12); color: #aaa; }
.status-default { background: rgba(100,100,100,0.1); color: #888; }
.sel-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
gap: 16px;
}
.amount-row { display: flex; gap: 32px; padding-top: 4px; flex-wrap: wrap; }
.amount-item { display: flex; flex-direction: column; gap: 4px; }
.amount-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.amount-val { font-size: 18px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
.amount-val.gold { color: var(--primary-light); }
.amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.card-title { font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 14px; padding-bottom: 10px; border-bottom: 1px solid var(--desktop-border); }
.sel-row { padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.04); }
.sel-row:last-child { border-bottom: none; }
.sel-row-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
.sel-index {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--bg-hover);
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
flex-shrink: 0;
}
.sel-info { min-width: 0; }
.sel-match { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 4px; }
.sel-meta { display: flex; gap: 10px; align-items: center; font-size: 12px; color: var(--text-muted); }
.sel-meta { display: flex; gap: 10px; align-items: center; font-size: 12px; color: var(--text-muted); flex-wrap: wrap; }
.sel-name { color: var(--text); font-weight: 700; }
.sel-odds { color: var(--primary-light); font-weight: 800; margin-left: auto; }
.sel-score { color: var(--text-muted); }
.sel-row-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.sel-odds { color: var(--primary); font-weight: 800; font-size: 14px; }
.leg-result {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
font-size: 12px;
font-weight: 800;
}
.leg-won { background: rgba(52, 199, 89, 0.15); color: #4cd964; }
.leg-lost { background: rgba(255, 69, 58, 0.15); color: #ff453a; }
.leg-push { background: rgba(100, 100, 100, 0.15); color: #aaa; }
.leg-pending { background: rgba(0, 102, 204, 0.1); color: var(--primary-light); font-size: 10px; }
.summary-card { display: flex; flex-direction: column; gap: 16px; }
.summary-bet-no { display: flex; flex-direction: column; gap: 4px; }
.summary-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.summary-value { font-size: 14px; font-weight: 700; color: var(--text); }
.mono { font-family: var(--font-mono); letter-spacing: 0.03em; }
.summary-status { display: flex; }
.status-badge { display: inline-block; padding: 4px 14px; border-radius: 999px; font-size: 12px; font-weight: 700; }
.status-won { background: rgba(52, 199, 89, 0.12); color: #4cd964; }
.status-lost { background: rgba(255, 69, 58, 0.12); color: #ff453a; }
.status-pending { background: rgba(0, 102, 204, 0.12); color: var(--primary-light); }
.status-push { background: rgba(100, 100, 100, 0.12); color: #aaa; }
.status-cancelled { background: rgba(100, 100, 100, 0.1); color: #888; }
.status-default { background: rgba(100, 100, 100, 0.1); color: #888; }
.summary-amounts { display: flex; flex-direction: column; gap: 14px; padding-top: 16px; border-top: 1px solid var(--border); }
.summary-amount-item { display: flex; flex-direction: column; gap: 4px; }
.summary-amount-label { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
.summary-amount-val { font-size: 18px; font-weight: 800; color: var(--text); font-variant-numeric: tabular-nums; }
.summary-amount-val.accent { color: var(--primary); }
.summary-amount-val.date { font-size: 13px; color: var(--text-muted); font-weight: 600; }
.back-to-list-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
}
.back-to-list-btn:hover {
border-color: var(--border-active);
color: var(--primary-light);
background: rgba(0, 102, 204, 0.06);
}
@media (max-width: 900px) {
.detail-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -1,22 +1,48 @@
<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 DesktopWalletPageHeader from '../../components/desktop/DesktopWalletPageHeader.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);
const total = computed(() => allItems.value.length);
const items = computed(() => {
@@ -75,21 +101,26 @@ watch(pageSize, () => {
page.value = 1;
});
onMounted(loadData);
onActivated(loadData);
onMounted(async () => {
await loadData();
openDetailFromQuery();
});
onActivated(async () => {
await loadData();
openDetailFromQuery();
});
</script>
<template>
<div class="desktop-records-page">
<header class="desktop-records-header">
<div class="desktop-records-header-main">
<DesktopWalletSubNav />
</div>
<div v-if="!loading && allItems.length" class="desktop-records-actions summary-chip">
<span class="summary-label">{{ t('cashback.total_received') }}</span>
<span class="summary-value">{{ formatMoney(totalAmount, locale) }}</span>
</div>
</header>
<DesktopWalletPageHeader :show-recharge="false">
<template v-if="!loading && allItems.length" #actions>
<div class="summary-chip">
<span class="summary-label">{{ t('cashback.total_received') }}</span>
<span class="summary-value">{{ formatMoney(totalAmount, locale) }}</span>
</div>
</template>
</DesktopWalletPageHeader>
<section class="desktop-records-panel">
<div class="desktop-records-table-wrap">
@@ -113,7 +144,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 +176,8 @@ onActivated(loadData);
/>
</div>
</section>
<DesktopCashbackDetailModal v-model:visible="detailModalVisible" :record="detailRecord" />
</div>
</template>
@@ -149,9 +187,9 @@ onActivated(loadData);
align-items: baseline;
gap: 8px;
padding: 8px 14px;
border: 1px solid rgba(212, 175, 55, 0.22);
border: 1px solid rgba(0, 102, 204, 0.22);
border-radius: 8px;
background: rgba(212, 175, 55, 0.08);
background: rgba(0, 102, 204, 0.08);
}
.summary-label {
@@ -168,7 +206,7 @@ onActivated(loadData);
}
.batch-cell {
font-family: 'SF Mono', Consolas, monospace;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-muted);
}

View File

@@ -11,6 +11,9 @@ import {
isAfterLocalTodayMatchWindow as isAfterTodayMatchWindow,
isInLocalTodayMatchWindow as isInTodayMatchWindow,
} from '@thebet365/shared';
import { useAuthStore } from '../../stores/auth';
import type { OutrightEvent, OutrightSelection } from '../../components/outright/OutrightEventSection.vue';
import OutrightBetModal, { type OutrightPick } from '../../components/outright/OutrightBetModal.vue';
type MainTab = 'matches' | 'outright';
@@ -102,6 +105,22 @@ const leagueGroups = computed(() => {
return Object.values(groups).sort((a, b) => a.leagueName.localeCompare(b.leagueName));
});
const filteredOutrightEvents = computed(() => {
if (mainTab.value !== 'outright') return [];
const keyword = searchQuery.value.trim().toLowerCase();
return outrightEvents.value.filter((e) => {
if (keyword) {
const haystack = `${e.title} ${e.leagueName || ''}`.toLowerCase();
if (!haystack.includes(keyword)) return false;
}
if (filterState.value.leagueIds.length > 0) {
const id = e.leagueId ?? e.leagueName;
if (!filterState.value.leagueIds.includes(id)) return false;
}
return true;
});
});
function goMatch(id: string) {
router.push(`/match/${id}`);
}
@@ -110,6 +129,32 @@ function goOutright(id: string) {
router.push(`/outright/${id}`);
}
const auth = useAuthStore();
const modalOpen = ref(false);
const activePick = ref<OutrightPick | null>(null);
function openBet(event: OutrightEvent, sel: OutrightSelection) {
if (event.bettingOpen === false || event.status === 'SETTLED') return;
if (!auth.token) {
auth.showLoginPrompt(route.fullPath);
return;
}
activePick.value = {
selectionId: sel.id,
oddsVersion: sel.oddsVersion,
teamCode: sel.teamCode,
teamName: sel.teamName,
odds: sel.odds,
eventTitle: event.title,
};
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
activePick.value = null;
}
async function refresh() {
if (mainTab.value === 'outright') {
await loadOutrights({ silent: false });
@@ -131,7 +176,7 @@ void loadParlayMatches(true);
:class="{ active: mainTab === 'matches' }"
@click="setMainTab('matches')"
>
{{ t('bet.tab_matches') }}
{{ t('bet.tab_matches') }}
</button>
<button
type="button"
@@ -139,7 +184,7 @@ void loadParlayMatches(true);
:class="{ active: mainTab === 'outright' }"
@click="setMainTab('outright')"
>
🏆 {{ t('bet.tab_outright') }}
{{ t('bet.tab_outright') }}
</button>
</div>
<button
@@ -179,25 +224,26 @@ void loadParlayMatches(true);
</div>
<div v-else class="outright-panel-wrap">
<div v-if="outrightLoading && !outrightEvents.length" class="skeleton-container">
<div v-if="outrightLoading && !filteredOutrightEvents.length" class="skeleton-container">
<div v-for="i in 4" :key="i" class="skeleton-card desktop-skeleton"></div>
</div>
<template v-else-if="outrightEvents.length">
<p v-if="outrightEvents.length > 1" class="outright-summary">
<template v-else-if="filteredOutrightEvents.length">
<p v-if="filteredOutrightEvents.length > 1" class="outright-summary">
{{
t('bet.outright_events_summary', {
events: outrightEvents.length,
teams: outrightEvents.reduce((n, e) => n + e.selections.length, 0),
events: filteredOutrightEvents.length,
teams: filteredOutrightEvents.reduce((n, e) => n + e.selections.length, 0),
})
}}
</p>
<div class="outright-event-grid">
<DesktopOutrightEventCard
v-for="event in outrightEvents"
v-for="event in filteredOutrightEvents"
:key="event.id"
:event="event"
@open="goOutright(event.id)"
@pick="(sel) => openBet(event, sel)"
/>
</div>
</template>
@@ -207,6 +253,8 @@ void loadParlayMatches(true);
<p v-if="!outrightError" class="empty-hint">{{ t('bet.no_outright_hint') }}</p>
</div>
</div>
<OutrightBetModal :open="modalOpen" :pick="activePick" @close="closeModal" />
</div>
</template>
@@ -238,20 +286,21 @@ void loadParlayMatches(true);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
font-family: var(--font-heading);
padding: 5px 10px;
border-radius: 4px;
transition: all 0.2s;
}
.main-tab:hover {
color: #fff;
background: rgba(255, 255, 255, 0.04);
color: var(--primary);
background: var(--bg-hover);
}
.main-tab.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
border: 1px solid var(--border-gold-soft);
background: rgba(0, 102, 204, 0.08);
border: 1px solid var(--border-active);
}
.refresh-btn {
@@ -293,8 +342,9 @@ void loadParlayMatches(true);
padding: 6px 10px;
font-size: 12px;
font-weight: 800;
font-family: var(--font-heading);
color: var(--primary-light);
background: rgba(212, 175, 55, 0.06);
background: rgba(0, 102, 204, 0.06);
border-left: 3px solid var(--primary);
border-radius: 0 4px 4px 0;
}

View File

@@ -1,99 +1,10 @@
<script setup lang="ts">
/**
* Desktop Forgot Password View
*/
import DesktopAuthLayout from '../../components/desktop/DesktopAuthLayout.vue';
import ForgotPasswordView from '../ForgotPasswordView.vue';
</script>
<template>
<div class="desktop-auth-shell">
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<h1 class="brand-name">THEBET365</h1>
<p class="brand-tagline">重置您的登录密码</p>
</div>
</div>
<div class="auth-card-wrap">
<div class="auth-card">
<ForgotPasswordView />
</div>
</div>
</div>
<DesktopAuthLayout wide>
<ForgotPasswordView desktop />
</DesktopAuthLayout>
</template>
<style scoped>
.desktop-auth-shell {
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
}
.auth-brand {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;
}
.auth-brand::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
pointer-events: none;
}
.brand-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
position: relative;
z-index: 1;
padding: 40px;
}
.brand-logo {
width: 64px;
height: 64px;
object-fit: contain;
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
}
.brand-name {
font-size: 28px;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--primary-light);
margin: 0;
}
.brand-tagline {
font-size: 14px;
color: var(--text-muted);
margin: 0;
font-weight: 600;
}
.auth-card-wrap {
width: 480px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--desktop-bg);
overflow-y: auto;
}
.auth-card {
width: 100%;
max-width: 380px;
}
</style>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { onMounted, computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
@@ -9,26 +9,27 @@ import TeamEmblem from '../../components/TeamEmblem.vue';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import type { PlayerHomeMatch } from '../../composables/usePlayerHome';
type HotTab = 'hot' | 'upcoming';
import { useDesktopBetPopover } from '../../composables/useDesktopBetPopover';
import { resolveSelectionLabel } from '../../utils/selectionLabel';
import MarketSelectionsPanel from '../../components/match-detail/MarketSelectionsPanel.vue';
const { t, locale } = useI18n();
const router = useRouter();
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
const activeTab = ref<HotTab>('hot');
const { openAt, visible: popoverVisible, pendingItem, cancelClose, scheduleClose, isActiveForCard } = useDesktopBetPopover();
const activeMarketTypes = ref<Record<string, string>>({});
const bannerFallbackTo = computed(() => {
const id = announcementItems.value[0]?.id;
return id ? `/announcements/${id}` : '/announcements';
});
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
);
const emptyMessage = computed(() =>
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
);
const featuredMatch = computed<PlayerHomeMatch | null>(() => hotMatches.value[0] ?? null);
const restHotMatches = computed<PlayerHomeMatch[]>(() => hotMatches.value.slice(1));
const upcomingList = computed<PlayerHomeMatch[]>(() => upcomingMatches.value);
const sideRecommendMatches = computed<PlayerHomeMatch[]>(() => hotMatches.value.slice(1, 5));
onMounted(() => {
void load(true);
@@ -38,103 +39,505 @@ function goMatch(id: string) {
router.push(`/match/${id}`);
}
function goPath(path: string) {
router.push(path);
}
function formatKickoff(startTime: string) {
return formatLocalMatchDateTime(startTime, locale.value, { variant: 'full' });
return formatLocalMatchDateTime(startTime, locale.value, {
variant: 'compact',
includeTimeZone: false,
});
}
function pickQuickBetMarkets(markets: PlayerHomeMatch['markets']) {
const supported = ['FT_1X2', 'FT_HANDICAP', 'FT_OVER_UNDER'];
const byType = new Map<string, NonNullable<PlayerHomeMatch['markets']>[number]>();
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) as NonNullable<PlayerHomeMatch['markets']>;
}
function getActiveMarketType(match: PlayerHomeMatch) {
const matchId = match.id;
const available = pickQuickBetMarkets(match.markets);
const saved = activeMarketTypes.value[matchId];
if (saved && available.some((m) => m.marketType === saved)) return saved;
if (available.some((m) => m.marketType === 'FT_1X2')) return 'FT_1X2';
return available[0]?.marketType ?? 'FT_1X2';
}
function setActiveMarketType(matchId: string, marketType: string) {
activeMarketTypes.value[matchId] = marketType;
}
function getAvailableMarketsForMatch(match: PlayerHomeMatch) {
return pickQuickBetMarkets(match.markets);
}
function getActiveMarket(match: PlayerHomeMatch) {
const activeType = getActiveMarketType(match);
return pickQuickBetMarkets(match.markets).find((m) => m.marketType === activeType);
}
function getMarketTabLabel(type: string) {
const raw = (() => {
if (type === 'FT_1X2') return t('bet.market_ft_1x2');
if (type === 'FT_HANDICAP') return t('bet.market_ft_handicap');
if (type === 'FT_OVER_UNDER') return t('bet.market_ft_ou');
return type;
})();
return raw.replace(/鍏ㄥ満\s*|FT\s*|Penuh\s*/ig, '').trim();
}
function isMarketLocked(match: PlayerHomeMatch, market?: { status: string; allowSingle?: boolean; allowParlay?: boolean }) {
if (!market) return true;
if (match.bettingOpen === false) return true;
const status = (market.status ?? 'OPEN').toUpperCase();
if (status !== 'OPEN') return true;
return market.allowSingle !== true && market.allowParlay !== true;
}
function isSelected(id: string) {
return false;
}
function handleOddsClick(match: PlayerHomeMatch, 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: resolveSelectionLabel(t, sel.selectionCode || '', sel.selectionName, {
lineValue: 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 getSelLabel(sel: any, marketType: string, lineValue?: string | number | null) {
return resolveSelectionLabel(t, sel.selectionCode || '', sel.selectionName || '', { lineValue });
}
function getHoveredSideForMatch(match: PlayerHomeMatch | null, cardRootId: string) {
if (!match || !popoverVisible.value || !pendingItem.value) return '';
if (!isActiveForCard(cardRootId)) return '';
if (String(pendingItem.value.matchId) !== String(match.id)) return '';
const selId = pendingItem.value.selectionId;
// Scan all markets across all tabs to find the selection
for (const market of match.markets ?? []) {
const sel = market.selections?.find((s: any) => s.id === selId);
if (!sel) continue;
const code = (sel.selectionCode || '').toUpperCase();
if (code === 'HOME') return 'home';
if (code === 'AWAY') return 'away';
if (code === 'DRAW') return 'draw';
if (code === 'OVER') return 'home';
if (code === 'UNDER') return 'away';
if (code === 'ODD' || code === 'EVEN') return 'draw';
// Fallback: use selection index
const selIndex = market.selections?.findIndex((s: any) => s.id === selId) ?? -1;
if (selIndex === -1) continue;
if (market.marketType === 'FT_1X2') {
if (selIndex === 0) return 'home';
if (selIndex === 1) return 'draw';
if (selIndex === 2) return 'away';
} else {
if (selIndex === 0) return 'home';
if (selIndex === 1) return 'away';
}
}
return '';
}
</script>
<template>
<div class="desktop-home-view">
<section class="hero-banner-full">
<DesktopBanner3DCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<!-- [B] Hero Row: 3D Banner -->
<section class="hero-row anim-fade-up" style="--anim-delay:0ms">
<div class="hero-banner-full">
<DesktopBanner3DCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
</div>
</section>
<div class="content-row">
<div class="matches-hub">
<div class="hub-header">
<div class="tabs-list" role="tablist">
<button
type="button"
role="tab"
class="hub-tab"
:class="{ active: activeTab === 'hot' }"
:aria-selected="activeTab === 'hot'"
@click="activeTab = 'hot'"
>
{{ t('home.hot_tab') || '热门赛事' }}
</button>
<button
type="button"
role="tab"
class="hub-tab"
:class="{ active: activeTab === 'upcoming' }"
:aria-selected="activeTab === 'upcoming'"
@click="activeTab = 'upcoming'"
>
{{ t('home.upcoming_tab') || '即将开赛' }}
</button>
<!-- [D] 主区: 左主内容 + 右侧栏 -->
<div class="main-row">
<div class="main-col">
<!-- [D1] 偣璧涗簨 -->
<section class="content-section anim-fade-up" style="--anim-delay:160ms">
<div class="section-title">
<span class="st-line st-line-animated" aria-hidden="true"></span>
<span class="st-text">{{ t('home.section_featured') }}</span>
</div>
<button type="button" class="refresh-btn" :disabled="loading" @click="load(true)">
<span v-if="loading" class="spin"></span>
<span v-else> {{ t('bet.refresh') || '刷新' }}</span>
</button>
</div>
<!-- Loading State -->
<div v-if="loading && !displayedMatches.length" class="loading-state">
<GoldSpinner :active="true" />
</div>
<div v-if="loading && !hotMatches.length" class="loading-state">
<GoldSpinner :active="true" />
</div>
<!-- Matches Grid -->
<div v-else-if="displayedMatches.length" class="matches-grid">
<div
v-for="match in displayedMatches"
:key="match.id"
class="match-card clickable"
@click="goMatch(match.id)"
>
<div class="card-top">
<span class="league-tag">{{ match.leagueName }}</span>
<span class="live-indicator" v-if="(match as any).status === 'LIVE'">LIVE</span>
</div>
<div class="teams-versus">
<div class="team-side home">
<TeamEmblem
size="md"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="team-name">{{ match.homeTeamName }}</span>
<template v-else-if="featuredMatch">
<!-- 偣澶у崱 -->
<div
class="featured-match anim-fade-up"
:class="{ 'featured-match--engaged': isActiveForCard(`featured-${featuredMatch.id}`) }"
:data-bet-card-root="`featured-${featuredMatch.id}`"
style="--anim-delay:240ms"
@click.self="goMatch(featuredMatch.id)"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="featured-top" @click="goMatch(featuredMatch.id)">
<div class="featured-meta">
<span class="league-tag lg">{{ featuredMatch.leagueName }}</span>
<span
v-if="(featuredMatch as any).status === 'LIVE'"
class="live-indicator lg live-pulse"
>LIVE</span>
</div>
<span class="featured-time">{{ formatKickoff(featuredMatch.startTime) }}</span>
</div>
<div class="vs-divider">VS</div>
<div class="team-side away">
<TeamEmblem
size="md"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="team-name">{{ match.awayTeamName }}</span>
<div class="featured-teams" :class="getHoveredSideForMatch(featuredMatch, `featured-${featuredMatch.id}`)" @click="goMatch(featuredMatch.id)">
<div class="featured-team">
<TeamEmblem
size="lg"
:team-code="featuredMatch.homeTeamCode"
:team-name="featuredMatch.homeTeamName"
:logo-url="featuredMatch.homeTeamLogoUrl"
/>
<span class="featured-team-name">{{ featuredMatch.homeTeamName }}</span>
</div>
<div class="featured-vs vs-heartbeat">VS</div>
<div class="featured-team">
<TeamEmblem
size="lg"
:team-code="featuredMatch.awayTeamCode"
:team-name="featuredMatch.awayTeamName"
:logo-url="featuredMatch.awayTeamLogoUrl"
/>
<span class="featured-team-name">{{ featuredMatch.awayTeamName }}</span>
</div>
</div>
<div class="featured-markets">
<div
v-for="m in getAvailableMarketsForMatch(featuredMatch)"
:key="m.marketType"
class="featured-market-block"
>
<div class="featured-market-label">{{ getMarketTabLabel(m.marketType) }}</div>
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(featuredMatch, m)"
:line-value="m.lineValue"
:selections="m.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(featuredMatch!, m, selId, ev)"
/>
</div>
<div v-if="!getAvailableMarketsForMatch(featuredMatch).length" class="no-market-odds">
{{ t('bet.no_market_odds') }}
</div>
</div>
</div>
<div class="card-footer">
<span class="match-time">{{ formatKickoff(match.startTime) }}</span>
<button type="button" class="bet-action-btn">{{ t('nav.bet') || '投注' }}</button>
<!-- 鍏朵綑鐑 2 ?-->
<div v-if="restHotMatches.length" class="matches-grid">
<div
v-for="(match, idx) in restHotMatches"
:key="match.id"
class="match-card anim-fade-up"
:class="{ 'match-card--engaged': isActiveForCard(`hot-${match.id}`) }"
:data-bet-card-root="`hot-${match.id}`"
:style="{ '--anim-delay': (320 + idx * 60) + 'ms' }"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="match-info-side" @click="goMatch(match.id)">
<!-- Default info layout (visible at rest) -->
<div class="default-info-layout">
<div class="card-top">
<span class="league-tag">{{ match.leagueName }}</span>
<div class="top-meta">
<span class="live-indicator live-pulse" v-if="(match as any).status === 'LIVE'">LIVE</span>
<span class="match-time">{{ formatKickoff(match.startTime) }}</span>
</div>
</div>
<div class="teams-versus-compact">
<div class="team-row">
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="team-name">{{ match.homeTeamName }}</span>
</div>
<div class="vs-text">VS</div>
<div class="team-row">
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="team-name">{{ match.awayTeamName }}</span>
</div>
</div>
</div>
<!-- Hover versus layout (visible on hover) -->
<div class="hover-versus-layout" :class="getHoveredSideForMatch(match, `hot-${match.id}`)">
<div class="hover-team home-slide">
<TeamEmblem
size="md"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="hover-team-name">{{ match.homeTeamName }}</span>
</div>
<div class="hover-vs-glow">VS</div>
<div class="hover-team away-slide">
<TeamEmblem
size="md"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="hover-team-name">{{ match.awayTeamName }}</span>
</div>
</div>
</div>
<div class="match-market-side">
<div class="market-tabs">
<button
v-for="m in getAvailableMarketsForMatch(match)"
:key="m.marketType"
type="button"
class="market-tab-btn"
:class="{ active: getActiveMarketType(match) === m.marketType }"
@click="setActiveMarketType(match.id, m.marketType)"
>
{{ getMarketTabLabel(m.marketType) }}
</button>
</div>
<div class="market-odds-area">
<template v-if="getActiveMarket(match)">
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(match, getActiveMarket(match))"
:line-value="getActiveMarket(match)?.lineValue"
:selections="getActiveMarket(match)?.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(match, getActiveMarket(match)!, selId, ev)"
/>
</template>
<div v-else class="no-market-odds">
{{ t('bet.no_market_odds') }}
</div>
</div>
</div>
</div>
</div>
</template>
<div v-else class="empty-state">
<p>{{ t('home.no_matches') }}</p>
</div>
</section>
<!-- [D2] 鍗冲皢寮?-->
<section class="content-section anim-fade-up" style="--anim-delay:200ms">
<div class="section-title">
<span class="st-line" aria-hidden="true"></span>
<span class="st-text">{{ t('home.section_upcoming') }}</span>
</div>
<div v-if="upcomingList.length" class="matches-grid">
<div
v-for="(match, idx) in upcomingList"
:key="match.id"
class="match-card anim-fade-up"
:class="{ 'match-card--engaged': isActiveForCard(`upcoming-${match.id}`) }"
:data-bet-card-root="`upcoming-${match.id}`"
:style="{ '--anim-delay': (260 + idx * 50) + 'ms' }"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="match-info-side" @click="goMatch(match.id)">
<!-- Default info layout (visible at rest) -->
<div class="default-info-layout">
<div class="card-top">
<span class="league-tag">{{ match.leagueName }}</span>
<div class="top-meta">
<span class="match-time">{{ formatKickoff(match.startTime) }}</span>
</div>
</div>
<div class="teams-versus-compact">
<div class="team-row">
<TeamEmblem
size="sm"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="team-name">{{ match.homeTeamName }}</span>
</div>
<div class="vs-text">VS</div>
<div class="team-row">
<TeamEmblem
size="sm"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="team-name">{{ match.awayTeamName }}</span>
</div>
</div>
</div>
<!-- Hover versus layout (visible on hover) -->
<div class="hover-versus-layout" :class="getHoveredSideForMatch(match, `upcoming-${match.id}`)">
<div class="hover-team home-slide">
<TeamEmblem
size="md"
:team-code="match.homeTeamCode"
:team-name="match.homeTeamName"
:logo-url="match.homeTeamLogoUrl"
/>
<span class="hover-team-name">{{ match.homeTeamName }}</span>
</div>
<div class="hover-vs-glow">VS</div>
<div class="hover-team away-slide">
<TeamEmblem
size="md"
:team-code="match.awayTeamCode"
:team-name="match.awayTeamName"
:logo-url="match.awayTeamLogoUrl"
/>
<span class="hover-team-name">{{ match.awayTeamName }}</span>
</div>
</div>
</div>
<div class="match-market-side">
<div class="market-tabs">
<button
v-for="m in getAvailableMarketsForMatch(match)"
:key="m.marketType"
type="button"
class="market-tab-btn"
:class="{ active: getActiveMarketType(match) === m.marketType }"
@click="setActiveMarketType(match.id, m.marketType)"
>
{{ getMarketTabLabel(m.marketType) }}
</button>
</div>
<div class="market-odds-area">
<template v-if="getActiveMarket(match)">
<MarketSelectionsPanel
desktop-card
compact
:locked="isMarketLocked(match, getActiveMarket(match))"
:line-value="getActiveMarket(match)?.lineValue"
:selections="getActiveMarket(match)?.selections || []"
:is-selected="isSelected"
@pick="(selId, ev) => handleOddsClick(match, getActiveMarket(match)!, selId, ev)"
/>
</template>
<div v-else class="no-market-odds">
{{ t('bet.no_market_odds') }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<p>{{ emptyMessage }}</p>
</div>
<div v-else-if="!loading" class="empty-state">
<p>{{ t('home.upcoming_empty') }}</p>
</div>
</section>
</div>
<aside class="home-sidebar">
<!-- 鍙充晶澶氬崱杈规爮 -->
<aside class="home-sidebar anim-fade-up" style="--anim-delay:120ms">
<HomeAnnouncementCard />
<div v-if="sideRecommendMatches.length" class="side-card">
<div class="side-card-header">
<span class="side-card-title">{{ t('home.sidebar_recommend') }}</span>
</div>
<ul class="side-recommend-list">
<li
v-for="(m, idx) in sideRecommendMatches"
:key="m.id"
class="side-recommend-item anim-fade-up"
:class="{ 'side-recommend-item--engaged': isActiveForCard(`sidebar-${m.id}`) }"
:data-bet-card-root="`sidebar-${m.id}`"
:style="{ '--anim-delay': (200 + idx * 70) + 'ms' }"
@click="goMatch(m.id)"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="sri-top-row">
<span class="sri-league">{{ m.leagueName }}</span>
<span class="sri-time">{{ formatKickoff(m.startTime) }}</span>
</div>
<div class="sri-teams">
<span>{{ m.homeTeamName }}</span>
<span class="sri-vs">vs</span>
<span>{{ m.awayTeamName }}</span>
</div>
<!-- 诞棰勮?-->
<div class="sri-preview-card" @click.stop @mouseenter="cancelClose" @mouseleave="scheduleClose()">
<div class="sri-preview-top">
<span class="sri-preview-league">{{ m.leagueName }}</span>
<span class="sri-preview-time">{{ formatKickoff(m.startTime) }}</span>
</div>
<div class="sri-preview-vs" :class="getHoveredSideForMatch(m, `sidebar-${m.id}`)">
<div class="sri-preview-team home-team">
<TeamEmblem size="md" :team-code="m.homeTeamCode" :team-name="m.homeTeamName" :logo-url="m.homeTeamLogoUrl" />
<span>{{ m.homeTeamName }}</span>
</div>
<div class="sri-preview-vs-text">VS</div>
<div class="sri-preview-team away-team">
<TeamEmblem size="md" :team-code="m.awayTeamCode" :team-name="m.awayTeamName" :logo-url="m.awayTeamLogoUrl" />
<span>{{ m.awayTeamName }}</span>
</div>
</div>
<div v-if="getActiveMarket(m)" class="sri-preview-odds">
<div
v-for="sel in (getActiveMarket(m)?.selections || []).slice(0, 3)"
:key="sel.id"
class="sri-preview-odd-btn"
:class="{ selected: isSelected(sel.id) }"
@click.stop="handleOddsClick(m, getActiveMarket(m)!, sel.id, $event)"
>
<span class="sri-odd-label">{{ getSelLabel(sel, getActiveMarket(m)?.marketType || '', getActiveMarket(m)?.lineValue) }}</span>
<span class="sri-odd-val">{{ sel.odds }}</span>
</div>
</div>
<div v-else class="sri-preview-no-odds">{{ t('bet.no_markets') }}</div>
</div>
</li>
</ul>
</div>
</aside>
</div>
</div>
@@ -144,70 +547,82 @@ function formatKickoff(startTime: string) {
.desktop-home-view {
display: flex;
flex-direction: column;
gap: 20px;
gap: 16px;
width: 100%;
}
.hero-row {
display: block;
width: 100%;
}
.hero-banner-full {
width: 100%;
min-width: 0;
}
.content-row {
.qe-icon {
font-size: 24px;
line-height: 1;
}
.qe-label {
font-size: 12px;
font-weight: 700;
line-height: 1.2;
text-align: center;
}
/* [D] Main Row */
.main-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
align-items: start;
}
.home-sidebar {
.main-col {
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.home-sidebar :deep(.home-announce-card) {
border-radius: 12px;
}
.matches-hub {
background: rgba(20, 20, 20, 0.85);
.content-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
border-radius: var(--radius-lg);
padding: var(--space-6) var(--space-7);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition);
}
.hub-header {
/* section-title 閲戠嚎 */
.section-title {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--border);
margin-bottom: 20px;
padding-bottom: 10px;
gap: 12px;
margin-bottom: 14px;
}
.tabs-list {
display: flex;
gap: 16px;
.st-line {
width: 4px;
height: 18px;
border-radius: var(--radius-full);
background: linear-gradient(180deg, var(--primary), var(--primary-lighter));
box-shadow: 0 0 8px rgba(0, 102, 204, 0.4), var(--glow-gold);
}
.hub-tab {
background: transparent;
color: var(--text-muted);
font-size: 14px;
font-weight: 700;
padding: 8px 4px;
border-bottom: 2px solid transparent;
transition: all 0.2s;
.st-text {
font-size: 15px;
font-weight: 800;
font-family: var(--font-heading);
color: var(--text);
letter-spacing: 0.5px;
text-transform: uppercase;
}
.hub-tab:hover {
color: #fff;
}
.hub-tab.active {
color: var(--primary-light);
border-bottom-color: var(--primary);
}
.refresh-btn {
.st-refresh {
margin-left: auto;
background: transparent;
color: var(--primary-light);
font-size: 12px;
@@ -215,6 +630,12 @@ function formatKickoff(startTime: string) {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
}
.st-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
@@ -231,125 +652,923 @@ function formatKickoff(startTime: string) {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
min-height: 160px;
color: var(--text-muted);
}
.matches-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
.match-card {
background: #141414;
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
/* Featured 鐒︾偣澶у崱 */
.featured-match {
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.2s;
gap: var(--space-5);
padding: var(--space-7) var(--space-8);
border-radius: var(--radius-lg);
background: linear-gradient(180deg, #f8fafc 0%, var(--bg-card) 100%);
border: 1px solid var(--border-active);
box-shadow: var(--shadow-gold-lg);
margin-bottom: var(--space-5);
cursor: pointer;
transition: box-shadow var(--transition-slow), transform var(--transition);
position: relative;
overflow: hidden;
}
.match-card:hover {
border-color: var(--border-gold-soft);
box-shadow: var(--shadow-gold);
.featured-match::before {
content: '';
position: absolute;
top: 0; right: 0;
width: 140px; height: 140px;
background: radial-gradient(circle at top right, rgba(0, 102, 204, 0.12), transparent 65%);
pointer-events: none;
}
.card-top {
.featured-match:is(:hover, .featured-match--engaged) {
transform: translateY(-2px);
box-shadow: var(--shadow-xl), inset 0 0 24px rgba(0, 102, 204, 0.08), var(--glow-gold);
}
.featured-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.league-tag {
.featured-meta {
display: flex;
align-items: center;
gap: 10px;
}
.league-tag.lg {
font-size: 12px;
padding: 3px 8px;
max-width: 480px;
}
.live-indicator.lg {
font-size: 11px;
padding: 3px 6px;
}
.featured-time {
font-size: 13px;
font-weight: 700;
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
padding: 2px 6px;
border-radius: 4px;
color: var(--text-muted);
}
.featured-teams {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
padding: 10px 0;
}
.featured-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
min-width: 0;
}
.featured-team-name {
font-size: 18px;
font-weight: 800;
font-family: var(--font-heading);
color: var(--text);
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.live-indicator {
.featured-vs {
font-size: 22px;
font-weight: 900;
font-style: italic;
font-family: var(--font-heading);
color: var(--odds-accent, #F8971F);
text-shadow: 0 0 12px rgba(248, 151, 31, 0.35);
}
.featured-markets {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 10px -20px -18px;
padding: 10px 16px 14px;
background: #F0F4F8;
border-top: 1px solid rgba(0, 102, 204, 0.12);
}
.featured-market-block {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.featured-market-label {
font-size: 11px;
font-weight: 700;
font-family: var(--font-heading);
color: var(--primary);
text-align: center;
letter-spacing: 0.4px;
text-transform: uppercase;
}
/* 鏅€氳禌浜嬪崱鐗?grid锛? 鍒楋級 */
.matches-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
gap: 10px;
}
.match-card {
display: flex;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
transition: border-color var(--transition), box-shadow var(--transition), transform var(--transition);
min-height: 68px;
}
.match-card:is(:hover, .match-card--engaged) {
border-color: var(--border-active);
box-shadow: var(--shadow-md), var(--glow-gold);
transform: translateY(-2px);
}
.match-info-side {
flex: 1;
padding: 8px 12px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
border-right: 1px solid var(--border);
min-width: 0;
cursor: pointer;
background: #FCFDFE;
position: relative;
overflow: hidden;
}
.match-info-side > * {
position: relative;
z-index: 1;
}
.match-card:is(:hover, .match-card--engaged) .match-info-side {
background: var(--bg-hover);
}
.match-market-side {
width: 220px;
flex-shrink: 0;
padding: 6px 8px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 3px;
background: var(--bg-card);
}
.card-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.top-meta {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.league-tag {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: rgba(0, 102, 204, 0.08);
padding: 1px 6px;
border-radius: 3px;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
flex: 1;
min-width: 0;
line-height: 1.2;
}
.live-indicator {
font-size: 9px;
font-weight: 800;
background: var(--danger);
color: #fff;
padding: 1px 4px;
padding: 1px 3px;
border-radius: 2px;
line-height: 1;
}
.teams-versus {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
.match-time {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
.team-side {
.teams-versus-compact {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.vs-text {
font-size: 10px;
font-weight: 800;
color: var(--text-muted);
font-style: italic;
padding-left: 36px;
line-height: 1;
margin: -2px 0;
}
.team-row {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
gap: 8px;
min-width: 0;
}
.team-name {
font-size: 12px;
font-weight: 700;
color: #eee;
text-align: center;
max-width: 100px;
font-family: var(--font-heading);
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vs-divider {
font-size: 11px;
font-weight: 800;
.market-tabs {
display: flex;
gap: 2px;
border-bottom: 1px solid var(--border);
padding-bottom: 4px;
margin-bottom: 4px;
}
.market-tab-btn {
padding: 2px 6px;
font-size: 10px;
font-weight: 700;
border-radius: 4px;
background: transparent;
color: var(--text-muted);
background: #222;
width: 22px;
height: 22px;
border: none;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.market-tab-btn:hover {
background: var(--bg-hover);
color: var(--primary);
}
.market-tab-btn.active {
background: rgba(0, 61, 107, 0.08);
color: var(--primary);
}
.market-odds-area {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
width: 100%;
min-height: 0;
}
.card-footer {
.no-market-odds {
font-size: 9px;
color: var(--text-muted);
text-align: center;
padding: 2px 4px;
line-height: 1.3;
}
/* 鍙充晶鏍?*/
.home-sidebar {
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.home-sidebar :deep(.home-announce-card) {
border-radius: 12px;
}
.side-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-5) var(--space-6);
box-shadow: var(--shadow-xs);
}
.side-card-header {
display: flex;
align-items: center;
padding-bottom: 8px;
margin-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.side-card-title {
font-size: 13px;
font-weight: 800;
font-family: var(--font-heading);
color: var(--primary-light);
letter-spacing: 0.3px;
text-transform: uppercase;
}
.side-recommend-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.side-recommend-item {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.side-recommend-item:hover {
background: var(--bg-hover);
}
.sri-top-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
margin-bottom: 4px;
}
.sri-league {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: var(--border-gold-soft);
padding: 1px 4px;
border-radius: 3px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sri-teams {
font-size: 11px;
font-weight: 700;
color: var(--text);
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sri-vs {
color: var(--text-muted);
font-size: 9px;
font-weight: 600;
font-style: italic;
}
.sri-time {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
/* @media 鍗曞垪鍥為€€ */
@media (max-width: 1100px) {
.main-row {
grid-template-columns: minmax(0, 1fr);
}
.matches-grid {
grid-template-columns: minmax(0, 1fr);
}
.featured-markets {
grid-template-columns: minmax(0, 1fr);
}
.featured-team-name {
font-size: 15px;
}
}
/* ============================================================
鉁?ANIMATION SYSTEM
============================================================ */
/* --- 1. 閫氱敤鍏ュ満锛歠ade + slide-up --- */
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.anim-fade-up {
opacity: 0;
animation: fadeSlideUp 0.55s cubic-bezier(0.22, 1, 0.36, 1) forwards;
animation-delay: var(--anim-delay, 0ms);
}
/* --- 2. 榛勯噾鍏夋檿鍛煎惛鑴夊啿锛堢劍鐐瑰ぇ鍗?::before锛?--- */
@keyframes goldGlowPulse {
0%, 100% {
opacity: 0.18;
transform: scale(1);
}
50% {
opacity: 0.32;
transform: scale(1.15);
}
}
.featured-match::before {
animation: goldGlowPulse 3.5s ease-in-out infinite;
transform-origin: top right;
}
/* --- 3. VS 蹇冭烦 --- */
@keyframes vsHeartbeat {
0%, 100% { transform: scale(1); }
14% { transform: scale(1.12); }
28% { transform: scale(1); }
42% { transform: scale(1.06); }
56% { transform: scale(1); }
}
.vs-heartbeat {
animation: vsHeartbeat 2.8s ease-in-out infinite;
display: inline-block;
}
/* --- 4. LIVE 鑴夊啿闂儊 --- */
@keyframes livePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.7); }
50% { box-shadow: 0 0 0 5px rgba(220, 38, 38, 0); }
}
.live-pulse {
animation: livePulse 1.6s ease-out infinite;
}
/* --- 5. section 鏍囬閲戠嚎婊戝叆 --- */
@keyframes lineSlideIn {
from { transform: scaleY(0); opacity: 0; }
to { transform: scaleY(1); opacity: 1; }
}
.st-line-animated {
transform-origin: top center;
animation: lineSlideIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s both;
}
/* --- 6. 璧涗簨鍗$墖锛氶紶鏍囩Щ鍔ㄥ埌鍗$墖涓婃椂涓ゅ彧闃熶紞灞呬腑涓斿嚭鐜?VS 缁忓吀鍔ㄧ敾 --- */
.default-info-layout {
width: 100%;
transition: opacity 0.35s cubic-bezier(0.25, 0.8, 0.25, 1), transform 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
}
.match-card:is(:hover, .match-card--engaged) .default-info-layout {
opacity: 0;
transform: translateY(-8px) scale(0.95);
pointer-events: none;
}
.hover-versus-layout {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 20px;
padding: 8px 16px;
opacity: 0;
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: rgba(255, 255, 255, 0.98);
border-radius: 8px 0 0 8px;
}
.match-card:is(:hover, .match-card--engaged) .hover-versus-layout {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.hover-team.home-slide {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transform: translateX(-35px);
opacity: 0;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.05s, opacity 0.4s ease 0.05s;
min-width: 0;
flex: 1;
}
.match-card:is(:hover, .match-card--engaged) .hover-team.home-slide {
transform: translateX(0);
opacity: 1;
}
.hover-team.away-slide {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
transform: translateX(35px);
opacity: 0;
transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.05s, opacity 0.4s ease 0.05s;
min-width: 0;
flex: 1;
}
.match-card:is(:hover, .match-card--engaged) .hover-team.away-slide {
transform: translateX(0);
opacity: 1;
}
.hover-team-name {
font-size: 11px;
font-weight: 700;
font-family: var(--font-heading);
color: var(--text);
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hover-vs-glow {
font-size: 18px;
font-weight: 900;
font-style: italic;
color: var(--odds-accent, #F8971F);
transform: scale(2.8);
opacity: 0;
text-shadow:
0 0 10px rgba(248, 151, 31, 0.5),
0 0 20px rgba(248, 151, 31, 0.25);
transition: transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s, opacity 0.3s ease 0.1s;
flex-shrink: 0;
}
.match-card:is(:hover, .match-card--engaged) .hover-vs-glow {
transform: scale(1);
opacity: 1;
animation: vs-clash-impact 0.3s ease-out 0.35s;
}
@keyframes vs-clash-impact {
0% {
text-shadow: 0 0 25px rgba(248, 151, 31, 0.7);
transform: scale(1.25);
}
100% {
text-shadow: 0 0 8px rgba(248, 151, 31, 0.4);
transform: scale(1);
}
}
/* --- 7. 渚ф爮鍗$墖鏁翠綋 hover 涓婃诞 --- */
.side-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.side-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md), var(--glow-gold);
}
/* --- 8. 鎺ㄨ崘鍒楄〃 item 宸︿晶閲戠偣鎸囩ず hover --- */
.side-recommend-item {
position: relative;
transition: background 0.18s, transform 0.18s;
}
.side-recommend-item:hover {
background: rgba(0, 102, 204, 0.06);
transform: translateX(3px);
}
/* 榧犳爣绉诲姩閫氶亾妗ユ帴妗ワ紙闃叉鍦ㄩ棿闅欏涓㈠けhover鐘舵€侊紝楂樺害闄愬埗涓烘帹鑽愰」楂樺害锛岄槻姝㈤噸鍙犻敊璇級 */
.side-recommend-item::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: -20px;
width: 20px;
background: transparent;
pointer-events: auto;
}
/* --- 9. 鎺ㄨ崘璧涗簨鎮诞棰勮鍗?--- */
.sri-preview-card {
position: absolute;
top: 50%;
right: calc(100% + 6px);
transform: translateY(-50%) translateX(8px);
width: 210px;
background: var(--bg-card);
border: 1px solid var(--border-active);
border-radius: 8px;
padding: 8px 10px;
box-shadow: 0 6px 24px rgba(0, 61, 107, 0.12), 0 0 12px rgba(0, 102, 204, 0.06);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.25, 0.8, 0.25, 1);
z-index: 100;
display: flex;
flex-direction: column;
gap: 6px;
}
.side-recommend-item:is(:hover, .side-recommend-item--engaged) .sri-preview-card {
opacity: 1;
transform: translateY(-50%) translateX(0);
pointer-events: auto;
}
.sri-preview-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.match-time {
font-size: 11px;
color: var(--text-muted);
.sri-preview-league {
font-size: 10px;
font-weight: 700;
color: var(--primary-light);
background: var(--border-gold-soft);
padding: 2px 6px;
border-radius: 4px;
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bet-action-btn {
padding: 4px 12px;
border-radius: 4px;
border: 1px solid var(--border-gold-soft);
background: transparent;
color: var(--primary-light);
.sri-preview-time {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
.sri-preview-vs {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sri-preview-team {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
flex: 1;
min-width: 0;
opacity: 0;
transition: opacity 0.35s ease, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.sri-preview-team.home-team {
transform: translateX(-24px);
}
.sri-preview-team.away-team {
transform: translateX(24px);
}
.side-recommend-item:hover .sri-preview-team {
opacity: 1;
transform: translateX(0);
}
.sri-preview-team span {
font-size: 11px;
font-weight: 700;
transition: all 0.2s;
color: var(--text);
text-align: center;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.match-card:hover .bet-action-btn {
background: var(--primary);
color: #3D2800;
.sri-preview-vs-text {
font-size: 16px;
font-weight: 900;
font-style: italic;
color: var(--odds-accent, #F8971F);
text-shadow: 0 0 8px rgba(248, 151, 31, 0.35);
flex-shrink: 0;
opacity: 0;
transform: scale(2.2);
transition: opacity 0.3s ease 0.1s, transform 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.1s;
}
.side-recommend-item:hover .sri-preview-vs-text {
opacity: 1;
transform: scale(1);
}
.sri-preview-odds {
display: flex;
gap: 5px;
}
.sri-preview-odd-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 5px 4px;
border-radius: 6px;
background: var(--bg-hover);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.sri-preview-odd-btn:hover {
background: rgba(0, 102, 204, 0.08);
border-color: var(--border-active);
}
.sri-preview-odd-btn.selected {
background: rgba(0, 61, 107, 0.08);
border-color: var(--primary);
}
.sri-odd-label {
font-size: 9px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
.sri-odd-val {
font-size: 13px;
font-weight: 800;
color: var(--odds-accent, #F8971F);
}
.sri-preview-no-odds {
text-align: center;
font-size: 9px;
color: var(--text-muted);
padding: 4px 0;
}
/* --- Sri preview: team highlight on odds selection --- */
.sri-preview-team {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
}
.sri-preview-vs.home .sri-preview-team.home-team {
transform: translateX(0) scale(1.1) !important;
filter: brightness(1.3) saturate(1.2);
opacity: 1 !important;
}
.sri-preview-vs.home .sri-preview-team.away-team {
opacity: 0.35 !important;
transform: translateX(0) scale(0.92) !important;
filter: grayscale(0.6) brightness(0.7);
}
.sri-preview-vs.away .sri-preview-team.away-team {
transform: translateX(0) scale(1.1) !important;
filter: brightness(1.3) saturate(1.2);
opacity: 1 !important;
}
.sri-preview-vs.away .sri-preview-team.home-team {
opacity: 0.35 !important;
transform: translateX(0) scale(0.92) !important;
filter: grayscale(0.6) brightness(0.7);
}
.sri-preview-vs.draw .sri-preview-team {
transform: translateX(0) scale(1.05) !important;
filter: brightness(1.15) saturate(1.1);
opacity: 1 !important;
}
/* --- VS Selection Highlight: scale selected team, no VS animation --- */
.featured-team,
.hover-team {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
}
/* featured match (home) */
.featured-teams.home .featured-team:first-child {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
}
.featured-teams.home .featured-team:last-child {
opacity: 0.35;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
/* featured match (away) */
.featured-teams.away .featured-team:last-child {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
}
.featured-teams.away .featured-team:first-child {
opacity: 0.35;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
/* featured match (draw) */
.featured-teams.draw .featured-team {
transform: scale(1.05);
filter: brightness(1.15) saturate(1.1);
}
/* hover-versus-layout (sidebar/upcoming) */
.hover-versus-layout.home .hover-team.home-slide {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
z-index: 2;
}
.hover-versus-layout.home .hover-team.away-slide {
opacity: 0.35 !important;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
.hover-versus-layout.away .hover-team.away-slide {
transform: scale(1.08);
filter: brightness(1.3) saturate(1.2);
z-index: 2;
}
.hover-versus-layout.away .hover-team.home-slide {
opacity: 0.35 !important;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
.hover-versus-layout.draw .hover-team.home-slide,
.hover-versus-layout.draw .hover-team.away-slide {
transform: scale(1.05);
filter: brightness(1.15) saturate(1.1);
}
</style>

View File

@@ -181,11 +181,11 @@ onActivated(refreshUnreadCount);
position: relative;
}
.hub-tab:hover { color: #fff; }
.hub-tab:hover { color: var(--primary); }
.hub-tab.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
background: rgba(0, 102, 204, 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: #333;
background: var(--bg-hover);
flex-shrink: 0;
transition: all 0.2s;
}

View File

@@ -1,134 +1,10 @@
<script setup lang="ts">
/**
* Desktop Login View
* Reuses the existing LoginView form in a centered two-column auth layout.
*/
import DesktopAuthLayout from '../../components/desktop/DesktopAuthLayout.vue';
import LoginView from '../LoginView.vue';
</script>
<template>
<div class="desktop-auth-shell">
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<h1 class="brand-name">THEBET365</h1>
<p class="brand-tagline">专业体育赛事投注平台</p>
<div class="brand-features">
<div class="feature-item">
<span class="feature-icon"></span>
<span>实时赔率更新</span>
</div>
<div class="feature-item">
<span class="feature-icon">🔒</span>
<span>安全稳定平台</span>
</div>
<div class="feature-item">
<span class="feature-icon">💰</span>
<span>快速充值提款</span>
</div>
</div>
</div>
</div>
<div class="auth-card-wrap">
<div class="auth-card">
<LoginView />
</div>
</div>
</div>
<DesktopAuthLayout>
<LoginView desktop />
</DesktopAuthLayout>
</template>
<style scoped>
.desktop-auth-shell {
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
}
.auth-brand {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;
}
.auth-brand::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
pointer-events: none;
}
.brand-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
position: relative;
z-index: 1;
padding: 40px;
}
.brand-logo {
width: 64px;
height: 64px;
object-fit: contain;
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
}
.brand-name {
font-size: 28px;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--primary-light);
margin: 0;
text-shadow: 0 2px 12px rgba(212,175,55,0.25);
}
.brand-tagline {
font-size: 14px;
color: var(--text-muted);
margin: 0;
font-weight: 600;
}
.brand-features {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 24px;
}
.feature-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-muted);
font-weight: 600;
}
.feature-icon {
font-size: 16px;
}
.auth-card-wrap {
width: 480px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--desktop-bg);
}
.auth-card {
width: 100%;
max-width: 380px;
}
</style>

View File

@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import { useBetSlipStore } from '../../stores/betSlip';
import { useAuthStore } from '../../stores/auth';
import TeamEmblem from '../../components/TeamEmblem.vue';
import MatchBetGuide from '../../components/match-detail/MatchBetGuide.vue';
import MarketSelectionsPanel from '../../components/match-detail/MarketSelectionsPanel.vue';
@@ -22,8 +21,7 @@ const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const slip = useBetSlipStore();
const auth = useAuthStore();
const { openAt, visible: popoverVisible, pendingItem: popoverItem } = useDesktopBetPopover();
const { openAt, visible: popoverVisible, pendingItem: popoverItem, cancelClose, scheduleClose } = useDesktopBetPopover();
const { pendingLocate, clearLocate } = useDesktopBetLocate();
function goBack() {
@@ -291,14 +289,39 @@ function buildSlipItem(sel: Selection, market: Market) {
function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
if (!match.value || isMarketLocked(market)) return;
if (!auth.token) {
auth.showLoginPrompt(route.fullPath);
return;
}
const item = buildSlipItem(sel, market);
if (!item || !event) return;
openAt(event, item);
}
function getHoveredSide() {
if (!match.value || !popoverVisible.value || !popoverItem.value) return '';
if (String(popoverItem.value.matchId) !== String(match.value.id)) return '';
const selId = popoverItem.value.selectionId;
for (const market of allMarkets.value) {
const sel = market.selections?.find((s) => s.id === selId);
if (!sel) continue;
const code = (sel.selectionCode || '').toUpperCase();
if (code === 'HOME') return 'home';
if (code === 'AWAY') return 'away';
if (code === 'DRAW') return 'draw';
if (code === 'OVER') return 'home';
if (code === 'UNDER') return 'away';
if (code === 'ODD' || code === 'EVEN') return 'draw';
const selIndex = market.selections?.findIndex((s) => s.id === selId) ?? -1;
if (selIndex === -1) continue;
if (market.marketType === 'FT_1X2') {
if (selIndex === 0) return 'home';
if (selIndex === 1) return 'draw';
if (selIndex === 2) return 'away';
} else {
if (selIndex === 0) return 'home';
if (selIndex === 1) return 'away';
}
}
return '';
}
</script>
<template>
@@ -318,7 +341,12 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
<template v-else-if="match">
<!-- High Density Score Band -->
<div class="match-score-band" :class="{ 'phase-settled': matchPhase === 'settled' }">
<div
class="match-score-band"
:class="[{ 'phase-settled': matchPhase === 'settled' }, getHoveredSide()]"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="team-side home">
<TeamEmblem size="xl" :team-code="match.homeTeamCode" :team-name="match.homeTeamName" :logo-url="match.homeTeamLogoUrl" />
<span class="name">{{ match.homeTeamName }}</span>
@@ -382,6 +410,8 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
:key="market.id"
class="market-card"
:class="{ locked: isMarketLocked(market) }"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="market-hdr">
<span class="market-title">{{ marketLabel(market) }}</span>
@@ -410,6 +440,8 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
:key="market.id"
class="score-block"
:class="{ locked: isMarketLocked(market) }"
@mouseenter="cancelClose"
@mouseleave="scheduleClose()"
>
<div class="score-block-hdr">
<span>{{ marketLabel(market) }}</span>
@@ -456,7 +488,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: #0c0c0c;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -503,7 +535,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.match-score-band {
background: linear-gradient(180deg, #181818 0%, #111 100%);
background: linear-gradient(180deg, var(--bg-body) 0%, var(--bg-card) 100%);
border: 1px solid var(--border);
border-radius: 4px;
padding: 10px 16px;
@@ -527,7 +559,8 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
.team-side .name {
font-size: 13px;
font-weight: 800;
color: #fff;
font-family: var(--font-heading);
color: var(--text);
}
.score-arena {
@@ -548,7 +581,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
font-size: 32px;
font-weight: 900;
color: var(--primary-light);
text-shadow: 0 0 10px rgba(212, 175, 55, 0.3);
text-shadow: 0 0 10px rgba(0, 102, 204, 0.3);
}
.versus-row .divider {
@@ -568,7 +601,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.result-stats-card {
background: rgba(20, 20, 20, 0.8);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px;
@@ -589,7 +622,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.stat-pill {
background: #141414;
background: var(--bg-body);
border: 1px solid var(--border);
border-radius: 4px;
padding: 6px 12px;
@@ -599,7 +632,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.stat-pill .lbl { color: var(--text-muted); }
.stat-pill .val { color: #fff; font-weight: 700; }
.stat-pill .val { color: var(--text); font-weight: 700; }
.market-filter-bar {
display: flex;
@@ -608,7 +641,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(18, 18, 18, 0.85);
background: var(--bg-card);
min-width: 0;
}
@@ -642,7 +675,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
padding: 3px 7px;
border-radius: 4px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.03);
background: rgba(0, 0, 0, 0.04);
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
@@ -652,15 +685,15 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
}
.market-filter-chip:hover {
color: #fff;
background: rgba(255, 255, 255, 0.06);
color: var(--primary);
background: var(--bg-hover);
}
.market-filter-chip.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.1);
border-color: var(--border-gold-soft);
box-shadow: inset 0 0 0 1px rgba(212, 175, 55, 0.08);
background: rgba(0, 102, 204, 0.1);
border-color: var(--border-active);
box-shadow: inset 0 0 0 1px rgba(0, 102, 204, 0.08);
}
.markets-score-section {
@@ -700,7 +733,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(18, 18, 18, 0.98);
background: var(--bg-card);
align-self: start;
}
@@ -714,14 +747,14 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #141414;
background: var(--bg-body);
border-bottom: 1px solid var(--border);
}
.market-title {
font-size: 11px;
font-weight: 800;
color: #eee;
color: var(--text);
line-height: 1.25;
}
@@ -741,7 +774,7 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: rgba(18, 18, 18, 0.98);
background: var(--bg-card);
align-self: start;
}
@@ -755,11 +788,11 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
background: #141414;
background: var(--bg-body);
border-bottom: 1px solid var(--border);
font-size: 11px;
font-weight: 800;
color: #eee;
color: var(--text);
}
.locked-lbl {
@@ -768,4 +801,35 @@ function toggleSelection(sel: Selection, market: Market, event?: MouseEvent) {
font-weight: 700;
white-space: nowrap;
}
.team-side {
transition: transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease, filter 0.3s ease;
}
.match-score-band.home .team-side.home {
transform: scale(1.06);
filter: brightness(1.08) saturate(1.15);
}
.match-score-band.home .team-side.away {
opacity: 0.35;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
.match-score-band.away .team-side.away {
transform: scale(1.06);
filter: brightness(1.08) saturate(1.15);
}
.match-score-band.away .team-side.home {
opacity: 0.35;
transform: scale(0.94);
filter: grayscale(0.6) brightness(0.7);
}
.match-score-band.draw .team-side {
transform: scale(1.04);
filter: brightness(1.08) saturate(1.1);
}
</style>

View File

@@ -80,6 +80,7 @@ onActivated(refreshUnreadCount);
.desktop-hub-page {
flex: 1;
min-height: 0;
height: 100%;
}
.hub-aside {
@@ -123,12 +124,12 @@ onActivated(refreshUnreadCount);
}
.hub-tab:hover {
color: #fff;
color: var(--primary);
}
.hub-tab.active {
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
background: rgba(0, 102, 204, 0.08);
}
.hub-tab.active .dot {
@@ -140,7 +141,7 @@ onActivated(refreshUnreadCount);
width: 6px;
height: 6px;
border-radius: 50%;
background: #333;
background: var(--bg-hover);
flex-shrink: 0;
}

View File

@@ -1,16 +1,38 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { RouterLink } 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);
const items = ref<BetHistoryItem[]>([]);
const total = ref(0);
@@ -60,8 +82,14 @@ useOnLocaleChange(() => {
void loadPage(1);
});
onActivated(() => { void loadPage(page.value); });
onMounted(() => { void loadPage(1); });
onActivated(() => {
void loadPage(page.value);
openDetailFromQuery();
});
onMounted(() => {
void loadPage(1);
openDetailFromQuery();
});
function statusClass(status: string) {
const map: Record<string, string> = {
@@ -149,10 +177,13 @@ function statusLabel(status: string) {
</td>
</tr>
<template v-else-if="items.length">
<tr v-for="bet in items" :key="bet.betNo" class="hover-row">
<td>
<RouterLink :to="`/bets/${bet.betNo}`" class="link-cell">{{ bet.betNo }}</RouterLink>
</td>
<tr
v-for="bet in items"
:key="bet.betNo"
class="hover-row clickable-row"
@click="openDetail(bet.betNo)"
>
<td class="id-cell">{{ bet.betNo }}</td>
<td>{{ bet.matchTitle || '-' }}</td>
<td class="muted-cell">{{ bet.pickLabel || '-' }}</td>
<td class="num-cell">{{ bet.totalOdds ?? '-' }}</td>
@@ -183,6 +214,8 @@ function statusLabel(status: string) {
/>
</div>
</section>
<DesktopBetDetailModal v-model:visible="detailModalVisible" :bet-no="detailBetNo" />
</div>
</template>
@@ -190,5 +223,7 @@ function statusLabel(status: string) {
.muted-cell {
color: var(--text-muted);
max-width: 280px;
font-size: 12px;
font-family: var(--font-data);
}
</style>

View File

@@ -9,10 +9,8 @@ import OutrightBetModal, { type OutrightPick } from '../../components/outright/O
import { useAuthStore } from '../../stores/auth';
import GoldSpinner from '../../components/GoldSpinner.vue';
import saishiImg from '../../assets/images/saishi.webp';
import cardBg from '../../assets/images/card-bg.webp';
import { useDesktopBetLocate, scrollToBetSelection } from '../../composables/useDesktopBetLocate';
const matchCardBg = `url(${cardBg})`;
const ODDS_POLL_MS = 15000;
const route = useRoute();
@@ -182,7 +180,7 @@ onUnmounted(stopPolling);
padding: 10px 16px;
font-size: 12px;
color: var(--text-muted);
background: #0c0c0c;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
@@ -237,19 +235,10 @@ onUnmounted(stopPolling);
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 6px;
background: rgba(18, 18, 18, 0.98);
background: var(--bg-card);
overflow: hidden;
}
.event-hero::before {
content: '';
position: absolute;
inset: 0;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
opacity: 0.22;
pointer-events: none;
}
.hero-text {
position: relative;
z-index: 1;
@@ -274,8 +263,8 @@ onUnmounted(stopPolling);
.settled-tag {
font-size: 10px;
font-weight: 800;
color: #c9a227;
border: 1px solid rgba(201, 162, 39, 0.45);
color: var(--odds-accent);
border: 1px solid rgba(248, 151, 31, 0.45);
border-radius: 999px;
padding: 1px 7px;
}
@@ -291,14 +280,14 @@ onUnmounted(stopPolling);
margin: 4px 0 0;
font-size: 11px;
font-weight: 600;
color: #666;
color: var(--text-muted);
}
.hero-hint {
margin: 8px 0 0;
font-size: 11px;
font-weight: 600;
color: #c9a227;
color: var(--odds-accent);
line-height: 1.45;
}

View File

@@ -8,6 +8,7 @@ import PlayerAvatarModal from '../../components/PlayerAvatarModal.vue';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import { useAuthStore } from '../../stores/auth';
import PageBackButton from '../../components/PageBackButton.vue';
const { t } = useI18n();
const router = useRouter();
@@ -110,6 +111,7 @@ async function onAvatarSelect(key: string | null) {
<main class="account-main">
<div class="edit-layout">
<div class="page-header">
<PageBackButton fallback="/wallet" />
<h1 class="page-title">{{ t('profile.edit_title') }}</h1>
</div>
@@ -216,7 +218,7 @@ async function onAvatarSelect(key: string | null) {
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
.account-main { flex: 1; min-width: 0; padding: 24px 28px; overflow-y: auto; }
.page-header { margin-bottom: 24px; }
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 24px; }
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
.edit-layout {
@@ -230,7 +232,7 @@ async function onAvatarSelect(key: string | null) {
.edit-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 10px;
padding: 20px;
}
@@ -260,14 +262,14 @@ async function onAvatarSelect(key: string | null) {
height: 64px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border-gold-soft);
border: 2px solid var(--border-active);
}
.avatar-placeholder {
width: 64px;
height: 64px;
border-radius: 50%;
background: #1a1a1a;
background: var(--bg-body);
display: flex;
align-items: center;
justify-content: center;
@@ -287,7 +289,7 @@ async function onAvatarSelect(key: string | null) {
transition: all 0.2s;
}
.change-avatar-btn:hover { border-color: var(--border-gold-soft); color: var(--primary-light); }
.change-avatar-btn:hover { border-color: var(--border-active); color: var(--primary-light); }
.field-group { display: flex; flex-direction: column; gap: 14px; margin-bottom: 16px; }
@@ -305,7 +307,7 @@ async function onAvatarSelect(key: string | null) {
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--desktop-border);
background: #111;
background: var(--bg-card);
color: var(--text);
font-size: 14px;
font-weight: 600;
@@ -313,7 +315,7 @@ async function onAvatarSelect(key: string | null) {
transition: border-color 0.2s;
}
.field-input:focus { border-color: var(--border-gold-soft); }
.field-input:focus { border-color: var(--border-active); }
.field-input:disabled { opacity: 0.45; cursor: not-allowed; }
.field-hint { font-size: 11px; color: var(--text-muted); margin: 0; }
@@ -329,7 +331,7 @@ async function onAvatarSelect(key: string | null) {
padding: 9px 24px;
border-radius: 6px;
background: var(--primary);
color: #111;
color: var(--bg-card);
font-size: 13px;
font-weight: 800;
border: none;

View File

@@ -11,7 +11,6 @@ import { usePlayerProfile } from '../../composables/usePlayerProfile';
import { useInboxFeature } from '../../composables/useInboxFeature';
import { formatMoney } from '../../utils/localeDisplay';
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
import walletBg from '../../assets/images/wallet-bg.webp';
const { t, locale } = useI18n();
const router = useRouter();
@@ -91,9 +90,6 @@ const balanceDisplay = computed(() =>
<template v-else>
<!-- Wallet Banner -->
<div class="wallet-banner">
<img class="wallet-banner-img" :src="walletBg" alt="" />
<div class="wallet-banner-scrim" aria-hidden="true" />
<div class="card-overlay">
<div class="bank-card-top">
<div class="bank-card-top-left">
@@ -199,46 +195,16 @@ const balanceDisplay = computed(() =>
aspect-ratio: 21 / 9;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.wallet-banner::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
box-shadow: inset 0 0 14px rgba(0, 0, 0, 0.22);
pointer-events: none;
}
.wallet-banner-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
transform: scale(1.05);
filter: brightness(0.86);
pointer-events: none;
user-select: none;
}
.wallet-banner-scrim {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.18) 42%, rgba(0,0,0,0.46) 100%);
box-shadow: 0 4px 16px rgba(0, 61, 107, 0.18);
background: linear-gradient(135deg, #003D6B 0%, #005B9F 52%, #002847 100%);
}
.card-overlay {
position: absolute;
inset: 0;
z-index: 3;
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 24px;
}
@@ -276,7 +242,7 @@ const balanceDisplay = computed(() =>
.bank-card-type {
font-size: 10px;
font-weight: 800;
color: rgba(212, 175, 55, 0.8);
color: rgba(255, 255, 255, 0.72);
font-style: italic;
}
@@ -286,17 +252,16 @@ const balanceDisplay = computed(() =>
gap: 4px;
padding: 8px 16px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(212, 175, 55, 0.35);
color: var(--primary-light);
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.28);
color: #fff;
font-size: 13px;
font-weight: 600;
text-decoration: none;
backdrop-filter: blur(6px);
transition: background 0.15s ease;
}
.recharge-btn:hover { background: rgba(212, 175, 55, 0.15); }
.recharge-btn:hover { background: rgba(255, 255, 255, 0.2); }
.bank-card-balance {
flex: 1;
@@ -310,16 +275,15 @@ const balanceDisplay = computed(() =>
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.82);
color: rgba(255, 255, 255, 0.72);
}
.bank-card-number {
margin: 0;
font-family: 'SF Mono', 'Consolas', monospace;
font-family: var(--font-mono);
font-weight: 700;
color: var(--primary-light);
color: #fff;
font-size: 38px;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}
.bank-card-footer {
@@ -355,7 +319,7 @@ const balanceDisplay = computed(() =>
.info-card, .lang-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 10px;
padding: 20px;
}
@@ -376,7 +340,7 @@ const balanceDisplay = computed(() =>
gap: 12px;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.info-label { width: 80px; flex-shrink: 0; font-size: 12px; color: var(--text-muted); font-weight: 600; }
@@ -388,7 +352,7 @@ const balanceDisplay = computed(() =>
align-items: center;
padding: 7px 18px;
border-radius: 6px;
border: 1px solid var(--border-gold-soft);
border: 1px solid var(--border-active);
color: var(--primary-light);
font-size: 13px;
font-weight: 700;
@@ -396,7 +360,7 @@ const balanceDisplay = computed(() =>
transition: background 0.2s;
}
.edit-btn:hover { background: rgba(212,175,55,0.08); }
.edit-btn:hover { background: rgba(0, 102, 204,0.08); }
.lang-group { display: flex; gap: 8px; flex-wrap: wrap; }
@@ -416,9 +380,9 @@ const balanceDisplay = computed(() =>
}
.lang-opt.active {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
color: var(--primary-light);
background: rgba(212,175,55,0.08);
background: rgba(0, 102, 204,0.08);
}
.logout-btn {

View File

@@ -1,30 +1,50 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { RouterLink } 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 DesktopWalletSubNav from '../../components/desktop/DesktopWalletSubNav.vue';
import DesktopWalletPageHeader from '../../components/desktop/DesktopWalletPageHeader.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;
approvedAmount?: string | null;
createdAt: string;
};
const detailModalVisible = ref(false);
const detailOrder = ref<PlayerDepositOrder | null>(null);
const items = ref<RechargeOrder[]>([]);
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) 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;
});
const items = ref<PlayerDepositOrder[]>([]);
const loading = ref(true);
const page = ref(1);
const pageSize = ref(20);
@@ -46,10 +66,16 @@ 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) {
function methodLabel(order: PlayerDepositOrder) {
if (order.methodType === 'USDT') return 'USDT';
return order.paymentMethodName || order.bankName || order.methodType || '-';
}
@@ -73,16 +99,7 @@ function statusLabel(status: string) {
<template>
<div class="desktop-records-page">
<header class="desktop-records-header">
<div class="desktop-records-header-main">
<DesktopWalletSubNav />
</div>
<div class="desktop-records-actions">
<RouterLink to="/wallet/recharge" class="desktop-records-action-btn">
{{ t('wallet.recharge_btn') }}
</RouterLink>
</div>
</header>
<DesktopWalletPageHeader />
<section class="desktop-records-panel">
<div class="desktop-records-table-wrap">
@@ -105,8 +122,13 @@ function statusLabel(status: string) {
</td>
</tr>
<template v-else-if="items.length">
<tr v-for="order in items" :key="order.id" class="hover-row">
<td class="order-no">{{ order.orderNo || order.id }}</td>
<tr
v-for="order in items"
:key="order.id"
class="hover-row clickable-row"
@click="openDetail(order)"
>
<td class="id-cell">{{ order.orderNo || order.id }}</td>
<td>{{ methodLabel(order) }}</td>
<td class="num-cell">{{ formatMoney(order.amount, locale) }}</td>
<td>
@@ -134,12 +156,7 @@ function statusLabel(status: string) {
/>
</div>
</section>
<DesktopRechargeDetailModal v-model:visible="detailModalVisible" :order="detailOrder" />
</div>
</template>
<style scoped>
.order-no {
font-weight: 700;
color: var(--primary-light);
}
</style>

View File

@@ -6,6 +6,7 @@ import imageCompression from 'browser-image-compression';
import api from '../../api';
import GoldSpinner from '../../components/GoldSpinner.vue';
import { useDepositNotifications } from '../../composables/useDepositNotifications';
import PageBackButton from '../../components/PageBackButton.vue';
const { t } = useI18n();
const router = useRouter();
@@ -128,6 +129,7 @@ onActivated(fetchMethods);
<main class="account-main">
<div class="recharge-layout">
<div class="page-header">
<PageBackButton fallback="/wallet" />
<h1 class="page-title">{{ t('recharge.title') }}</h1>
</div>
@@ -268,6 +270,9 @@ onActivated(fetchMethods);
}
.page-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
@@ -319,7 +324,7 @@ onActivated(fetchMethods);
padding: 9px 24px;
border-radius: 6px;
background: var(--primary);
color: #111;
color: var(--bg-card);
font-size: 13px;
font-weight: 800;
border: none;
@@ -350,9 +355,9 @@ onActivated(fetchMethods);
}
.type-tab.active {
border-color: var(--border-gold-soft);
border-color: var(--border-active);
color: var(--primary-light);
background: rgba(212,175,55,0.08);
background: rgba(0, 102, 204,0.08);
}
.recharge-cols {
@@ -398,11 +403,11 @@ onActivated(fetchMethods);
transition: all 0.2s;
}
.method-card:hover { border-color: rgba(255, 255, 255, 0.15); }
.method-card:hover { border-color: rgba(0, 0, 0, 0.08); }
.method-card.selected {
border-color: var(--border-gold-soft);
background: rgba(212,175,55,0.06);
border-color: var(--border-active);
background: rgba(0, 102, 204,0.06);
}
.method-name {
@@ -425,7 +430,7 @@ onActivated(fetchMethods);
.bank-detail-card {
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid rgba(0, 0, 0, 0.04);
border-radius: 8px;
padding: 16px 18px;
display: flex;
@@ -457,7 +462,7 @@ onActivated(fetchMethods);
color: var(--text);
}
.mono { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.04em; }
.mono { font-family: var(--font-mono); letter-spacing: 0.04em; }
.break-all { word-break: break-all; }
.qr-wrap {
@@ -492,7 +497,7 @@ onActivated(fetchMethods);
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--desktop-border);
background: #111;
background: var(--bg-card);
color: var(--text);
font-size: 14px;
font-weight: 700;
@@ -501,7 +506,7 @@ onActivated(fetchMethods);
max-width: 320px;
}
.field-input:focus { border-color: var(--border-gold-soft); }
.field-input:focus { border-color: var(--border-active); }
.preview-wrap {
position: relative;
@@ -524,9 +529,9 @@ onActivated(fetchMethods);
width: 22px;
height: 22px;
border-radius: 50%;
background: #333;
background: var(--bg-hover);
border: none;
color: #fff;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
display: flex;
@@ -548,7 +553,7 @@ onActivated(fetchMethods);
transition: border-color 0.2s;
}
.upload-zone:hover { border-color: var(--border-gold-soft); }
.upload-zone:hover { border-color: var(--border-active); }
.hidden-input { display: none; }
@@ -566,7 +571,7 @@ onActivated(fetchMethods);
padding: 11px 32px;
border-radius: 6px;
background: var(--primary);
color: #111;
color: var(--bg-card);
font-size: 14px;
font-weight: 800;
border: none;

View File

@@ -1,100 +1,10 @@
<script setup lang="ts">
/**
* Desktop Register View
* Reuses the existing RegisterView form in a centered desktop auth layout.
*/
import DesktopAuthLayout from '../../components/desktop/DesktopAuthLayout.vue';
import RegisterView from '../RegisterView.vue';
</script>
<template>
<div class="desktop-auth-shell">
<div class="auth-brand">
<div class="brand-inner">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<h1 class="brand-name">THEBET365</h1>
<p class="brand-tagline">加入我们开始精彩体育投注</p>
</div>
</div>
<div class="auth-card-wrap">
<div class="auth-card">
<RegisterView />
</div>
</div>
</div>
<DesktopAuthLayout wide>
<RegisterView desktop />
</DesktopAuthLayout>
</template>
<style scoped>
.desktop-auth-shell {
display: flex;
min-height: 100vh;
background: var(--desktop-bg);
}
.auth-brand {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0a0a0a 0%, #111 50%, #0d0d0d 100%);
border-right: 1px solid var(--desktop-border);
position: relative;
overflow: hidden;
}
.auth-brand::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse 60% 60% at 50% 40%, rgba(212,175,55,0.06) 0%, transparent 70%);
pointer-events: none;
}
.brand-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
position: relative;
z-index: 1;
padding: 40px;
}
.brand-logo {
width: 64px;
height: 64px;
object-fit: contain;
filter: drop-shadow(0 4px 16px rgba(212,175,55,0.3));
}
.brand-name {
font-size: 28px;
font-weight: 900;
letter-spacing: 0.12em;
color: var(--primary-light);
margin: 0;
}
.brand-tagline {
font-size: 14px;
color: var(--text-muted);
margin: 0;
font-weight: 600;
}
.auth-card-wrap {
width: 520px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--desktop-bg);
overflow-y: auto;
}
.auth-card {
width: 100%;
max-width: 420px;
}
</style>

View File

@@ -1,28 +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 DesktopWalletPageHeader from '../../components/desktop/DesktopWalletPageHeader.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();
function goBack() {
if (window.history.state && window.history.state.back) {
router.back();
} else {
router.push('/wallet');
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);
type Transaction = {
transactionType: string;
displayType?: string;
summary?: string | null;
referenceType?: string | null;
amount: string;
createdAt: string;
transactionId: string;
@@ -31,9 +45,10 @@ type Transaction = {
const items = ref<Transaction[]>([]);
const loading = ref(true);
const page = ref(1);
const hasMore = ref(true);
const pageSize = ref(20);
const total = ref(0);
function txLabel(tx: Transaction): string {
function txLabel(tx: Transaction) {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
@@ -42,152 +57,107 @@ function txLabel(tx: Transaction): string {
return tx.transactionType;
}
async function loadPage(p: number) {
function txSubtitle(tx: Transaction): string {
return txSummaryLabel(tx, t);
}
function amountClass(amount: string) {
return `desktop-records-amount ${txAmountClass(amount)}`;
}
async function fetchData(p = 1) {
loading.value = true;
try {
const { data } = await api.get('/player/wallet/transactions', { params: { page: p } });
const result = data.data ?? { items: [], total: 0 };
if (p === 1) {
items.value = result.items ?? [];
} else {
items.value = [...items.value, ...(result.items ?? [])];
}
hasMore.value = items.value.length < (result.total ?? 0);
const { data } = await api.get('/player/wallet/transactions', {
params: { page: p, pageSize: pageSize.value },
});
const result = data.data ?? { items: [], total: 0, pageSize: pageSize.value };
total.value = result.total ?? 0;
items.value = result.items ?? [];
page.value = p;
} catch {
if (p === 1) items.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
onMounted(() => loadPage(1));
onActivated(() => loadPage(1));
onMounted(() => {
void fetchData(1);
openDetailFromQuery();
});
onActivated(() => {
void fetchData(page.value);
openDetailFromQuery();
});
</script>
<template>
<div class="desktop-account-page">
<main class="account-main">
<div class="records-layout">
<div class="page-header">
<button type="button" class="back-btn" @click="router.back()"> {{ t('common.back') }}</button>
<h1 class="page-title">{{ t('wallet.all_records') }}</h1>
</div>
<div class="desktop-records-page">
<DesktopWalletPageHeader />
<div v-if="loading && items.length === 0" class="loading-state">
<GoldSpinner :size="36" />
</div>
<template v-else>
<div v-if="items.length" class="table-container">
<div class="table-wrap">
<table class="dt">
<thead>
<tr>
<th>{{ t('wallet.type') || '类型' }}</th>
<th>{{ t('wallet.note') || '备注' }}</th>
<th class="num-th">{{ t('wallet.amount') || '金额' }}</th>
<th>{{ t('wallet.time') || '时间' }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr
v-for="tx in items"
:key="tx.transactionId"
class="hover-row"
style="cursor:pointer"
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
>
<td class="type-cell">{{ txLabel(tx) }}</td>
<td class="note-cell">{{ txSummaryLabel(tx, t) || '-' }}</td>
<td :class="['num-cell', txAmountClass(tx.amount)]">{{ formatMoney(tx.amount, locale) }}</td>
<td class="date-cell">{{ new Date(tx.createdAt).toLocaleString() }}</td>
<td class="arrow-cell"></td>
</tr>
</tbody>
</table>
<div v-if="hasMore" class="load-more">
<button type="button" class="load-more-btn" :disabled="loading" @click="loadPage(page + 1)">
<GoldSpinner v-if="loading" :size="18" />
<span v-else>{{ t('common.load_more') || '加载更多' }}</span>
</button>
</div>
<p v-else class="end-hint">{{ t('common.no_more') }}</p>
</div>
</div>
<div v-else class="empty-state">{{ t('wallet.no_records') }}</div>
</template>
<section v-if="loading && !items.length" class="desktop-records-panel">
<div class="desktop-records-loading">
<GoldSpinner :size="36" />
</div>
</main>
</section>
<section v-else-if="items.length" class="desktop-records-panel">
<div class="desktop-records-table-wrap">
<table class="desktop-records-table">
<thead>
<tr>
<th>{{ t('wallet.type') }}</th>
<th>{{ t('wallet.note') }}</th>
<th class="num-th">{{ t('wallet.amount') }}</th>
<th>{{ t('wallet.time') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="tx in items"
:key="tx.transactionId"
class="hover-row clickable-row"
@click="openDetail(tx.transactionId)"
>
<td class="type-cell">{{ txLabel(tx) }}</td>
<td class="muted-cell">{{ txSubtitle(tx) || '-' }}</td>
<td :class="['num-cell', amountClass(tx.amount)]">{{ formatMoney(tx.amount, locale) }}</td>
<td class="date-cell">{{ new Date(tx.createdAt).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div class="desktop-records-pagination">
<Pagination
v-if="total > 0"
v-model="page"
v-model:pageSize="pageSize"
:total="total"
@change="fetchData"
/>
</div>
</section>
<section v-else class="desktop-records-panel">
<div class="desktop-records-empty">{{ t('wallet.no_records') }}</div>
</section>
<DesktopTxDetailModal v-model:visible="detailModalVisible" :transaction-id="detailTxId" />
</div>
</template>
<style scoped>
.desktop-account-page { display: flex; flex-direction: column; min-height: 0; flex: 1; width: 100%; }
.account-main { flex: 1; min-width: 0; padding: 24px 28px; display: flex; flex-direction: column; overflow: hidden; }
.records-layout {
display: flex;
flex-direction: column;
max-width: 800px;
margin: 0 auto;
width: 100%;
flex: 0 1 auto;
min-height: 0;
}
.page-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.back-btn { background: none; border: none; color: var(--text-muted); font-size: 14px; font-weight: 700; cursor: pointer; padding: 0; }
.back-btn:hover { color: var(--text); }
.page-title { font-size: 18px; font-weight: 800; color: var(--primary-light); margin: 0; }
.loading-state { display: flex; justify-content: center; align-items: center; padding: 80px 0; }
.table-container {
display: flex;
flex-direction: column;
flex: 0 1 auto;
min-height: 0;
background: var(--bg-card);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
overflow: hidden;
}
.table-wrap { flex: 1; overflow-x: auto; overflow-y: auto; }
.dt { width: 100%; border-collapse: collapse; font-size: 13px; }
.dt th {
position: sticky;
top: 0;
z-index: 10;
padding: 9px 12px;
text-align: left;
font-size: 11px;
.type-cell {
font-weight: 700;
color: var(--text-muted);
background: #141414;
text-transform: uppercase;
letter-spacing: 0.06em;
border-bottom: 1px solid var(--desktop-border);
white-space: nowrap;
font-family: var(--font-heading);
color: var(--text);
}
.muted-cell {
color: var(--text-muted);
max-width: 420px;
font-size: 12px;
}
.dt th:first-child { border-top-left-radius: 8px; }
.dt th:last-child { border-top-right-radius: 8px; }
.num-th { text-align: right; }
.dt td { padding: 11px 12px; border-bottom: 1px solid rgba(255,255,255,0.04); color: var(--text); vertical-align: middle; }
.hover-row { transition: background 0.15s; }
.hover-row:hover { background: rgba(255,255,255,0.03); }
.type-cell { font-weight: 700; }
.note-cell { color: var(--text-muted); font-size: 12px; max-width: 220px; }
.num-cell { text-align: right; font-variant-numeric: tabular-nums; font-weight: 700; }
.date-cell { white-space: nowrap; color: var(--text-muted); font-size: 12px; }
.arrow-cell { color: #555; font-size: 18px; font-weight: 300; text-align: right; width: 24px; }
.pos { color: var(--primary-light); }
.neg { color: var(--danger); }
.zero { color: var(--text-muted); }
.load-more { display: flex; justify-content: center; padding: 20px 0 8px; }
.load-more-btn { display: inline-flex; align-items: center; gap: 8px; padding: 8px 28px; border-radius: 6px; border: 1px solid var(--desktop-border); background: transparent; color: var(--text-muted); font-size: 13px; font-weight: 700; cursor: pointer; transition: all 0.2s; }
.load-more-btn:hover:not(:disabled) { border-color: var(--border-gold-soft); color: var(--primary-light); }
.load-more-btn:disabled { opacity: 0.5; cursor: default; }
.end-hint { text-align: center; font-size: 12px; color: #444; padding: 16px 0 4px; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 80px 20px; color: var(--text-muted); font-size: 14px; font-weight: 600; }
</style>

View File

@@ -165,7 +165,7 @@ function txLabel(tx: TxDetail): string {
font-size: 36px;
font-weight: 800;
font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Consolas', monospace;
font-family: var(--font-mono);
line-height: 1.1;
margin-bottom: 6px;
}
@@ -188,7 +188,7 @@ function txLabel(tx: TxDetail): string {
display: flex;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.04);
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
}
.detail-row:last-child { border-bottom: none; }
@@ -197,5 +197,5 @@ function txLabel(tx: TxDetail): string {
.row-val { flex: 1; font-size: 13px; font-weight: 700; color: var(--text); word-break: break-all; }
.row-val.pos { color: var(--primary-light); }
.row-val.neg { color: var(--danger); }
.mono { font-family: 'SF Mono', 'Consolas', monospace; font-size: 12px; letter-spacing: 0.03em; }
.mono { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.03em; }
</style>

View File

@@ -1,155 +1,428 @@
<script setup lang="ts">
import { ref, onMounted, onActivated } from 'vue';
import { ref, onMounted, onActivated, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, 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 DesktopWalletPageHeader from '../../components/desktop/DesktopWalletPageHeader.vue';
import LocaleFlag from '../../components/LocaleFlag.vue';
import { useAuthStore } from '../../stores/auth';
import { useAppLocale } from '../../composables/useAppLocale';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
import { formatMoney } from '../../utils/localeDisplay';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../../utils/walletTx';
import { parseCashbackApiData, sumCashbackAmount } from '../../utils/cashback';
import api from '../../api';
const { t, locale } = useI18n();
const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
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('0');
const loading = ref(true);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
function txLabel(tx: Transaction): string {
const key = txTypeKey(txDisplayType(tx));
if (key) {
const translated = t(key);
if (translated !== key) return translated;
async function fetchCashbackTotal() {
try {
const { data } = await api.get('/player/cashbacks');
cashbackTotal.value = sumCashbackAmount(parseCashbackApiData(data.data)).toString();
} catch {
try {
const { data } = await api.get('/player/wallet/transactions/stats');
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 {
// ignore
}
}
return tx.transactionType;
}
function txSubtitle(tx: Transaction): string {
return txSummaryLabel(tx, t);
}
function amountClass(amount: string) {
return `desktop-records-amount ${txAmountClass(amount)}`;
}
async function fetchData(p = 1) {
async function loadPage() {
loading.value = true;
try {
const [txRes, cbRes] = await Promise.all([
api.get('/player/wallet/transactions', { params: { page: p, pageSize: pageSize.value } }),
p === 1 ? api.get('/player/cashbacks').catch(() => ({ data: { data: [] } })) : Promise.resolve(null),
]);
const result = txRes.data.data ?? { items: [], total: 0, pageSize: pageSize.value };
total.value = result.total ?? 0;
if (p === 1) {
items.value = result.items ?? [];
const cbRows = parseCashbackApiData(cbRes?.data?.data);
cashbackTotal.value = sumCashbackAmount(cbRows).toString();
} else {
items.value = result.items ?? [];
}
page.value = p;
} catch {
/* ignore */
await refreshProfile();
initFromUser(profileRaw.value?.locale);
await fetchCashbackTotal();
} finally {
loading.value = false;
}
}
onMounted(() => fetchData(1));
onActivated(() => fetchData(1));
function logout() {
auth.logout();
router.push('/');
}
const balanceDisplay = computed(() =>
formatMoney(profileRaw.value?.wallet?.availableBalance, locale.value),
);
onMounted(loadPage);
onActivated(loadPage);
</script>
<template>
<div class="desktop-records-page">
<header class="desktop-records-header">
<div class="desktop-records-header-main">
<DesktopWalletSubNav />
</div>
<div class="desktop-records-actions">
<RouterLink to="/wallet/recharge" class="desktop-records-action-btn">
{{ t('wallet.recharge_btn') }}
</RouterLink>
</div>
</header>
<div class="desktop-records-page wallet-overview-page">
<DesktopWalletPageHeader />
<section v-if="loading && !items.length" class="desktop-records-panel">
<div class="desktop-records-loading">
<GoldSpinner :size="36" />
</div>
</section>
<div v-if="loading" class="wallet-loading">
<GoldSpinner :size="36" />
</div>
<section v-else-if="items.length" class="desktop-records-panel">
<div class="desktop-records-table-wrap">
<table class="desktop-records-table">
<thead>
<tr>
<th>{{ t('wallet.type') || '类型' }}</th>
<th>{{ t('wallet.note') || '备注' }}</th>
<th class="num-th">{{ t('wallet.amount') || '金额' }}</th>
<th>{{ t('wallet.time') || '时间' }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="tx in items"
:key="tx.transactionId"
class="hover-row"
style="cursor: pointer"
@click="router.push(`/wallet/transactions/${tx.transactionId}`)"
>
<td class="type-cell">{{ txLabel(tx) }}</td>
<td class="muted-cell">{{ txSubtitle(tx) || '-' }}</td>
<td :class="['num-cell', amountClass(tx.amount)]">{{ formatMoney(tx.amount, locale) }}</td>
<td class="date-cell">{{ new Date(tx.createdAt).toLocaleString() }}</td>
</tr>
</tbody>
</table>
</div>
<div class="desktop-records-pagination">
<Pagination
v-if="total > 0"
v-model="page"
v-model:pageSize="pageSize"
:total="total"
@change="fetchData"
/>
</div>
</section>
<template v-else>
<div class="wallet-overview-content">
<section class="wallet-hero">
<div class="wallet-hero-top">
<div class="wallet-hero-brand">
<img src="/logo.png" alt="" class="wallet-hero-logo" />
<span class="wallet-hero-brand-text">THEBET365</span>
</div>
<RouterLink to="/wallet/recharge" class="wallet-hero-recharge">
+ {{ t('recharge.title') }}
</RouterLink>
</div>
<div class="wallet-hero-body">
<span class="wallet-hero-label">{{ t('wallet.my_balance') }}</span>
<p class="wallet-hero-balance">{{ balanceDisplay }}</p>
</div>
</section>
<section v-else class="desktop-records-panel">
<div class="desktop-records-empty">
<span>{{ t('wallet.no_records') }}</span>
<div class="wallet-stat-grid">
<div class="wallet-stat-card">
<span class="wallet-stat-label">{{ t('profile.username') }}</span>
<span class="wallet-stat-value">{{ profileRaw?.username || '-' }}</span>
</div>
<div class="wallet-stat-card">
<span class="wallet-stat-label">{{ t('wallet.cashback_total') }}</span>
<span class="wallet-stat-value">{{ formatMoney(cashbackTotal, locale) }}</span>
</div>
<div class="wallet-stat-card">
<span class="wallet-stat-label">{{ t('wallet.frozen') }}</span>
<span class="wallet-stat-value">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
</section>
<section class="wallet-panel">
<div class="wallet-panel-row">
<div class="wallet-panel-label">{{ t('profile.account') }}</div>
<div class="wallet-panel-content">
<div class="wallet-info-line">
<span class="wallet-info-key">{{ t('profile.username') }}</span>
<span class="wallet-info-val">{{ profileRaw?.username }}</span>
</div>
<div class="wallet-info-line">
<span class="wallet-info-key">{{ t('profile.phone') }}</span>
<span class="wallet-info-val">{{ profileRaw?.preferences?.phone || '-' }}</span>
</div>
<RouterLink to="/profile/edit" class="wallet-panel-link">
{{ t('profile.edit') }}
<span aria-hidden="true"></span>
</RouterLink>
</div>
</div>
<div class="wallet-panel-row">
<div class="wallet-panel-label">{{ t('profile.language') }}</div>
<div class="wallet-panel-content">
<div class="wallet-lang-group" role="group" :aria-label="t('profile.language')">
<button
v-for="item in locales"
:key="item.code"
type="button"
class="wallet-lang-btn"
:class="{ active: locale === item.code }"
@click="setLocale(item.code)"
>
<LocaleFlag :locale="item.code" :size="14" />
<span>{{ item.label }}</span>
</button>
</div>
</div>
</div>
<div class="wallet-panel-row wallet-panel-row--actions">
<button type="button" class="wallet-logout-btn" @click="logout">
{{ t('auth.logout') }}
</button>
</div>
</section>
</div>
</template>
</div>
</template>
<style scoped>
.type-cell {
font-weight: 700;
.wallet-overview-page {
gap: 14px;
flex: 0 1 auto !important;
height: auto !important;
overflow: visible;
}
.muted-cell {
.wallet-overview-content {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
max-width: 640px;
margin: 0 auto;
}
.wallet-loading {
display: flex;
justify-content: center;
padding: 48px 0;
}
.wallet-hero {
padding: var(--space-8) var(--space-9);
border-radius: var(--radius-xl);
background: linear-gradient(135deg, #003D6B 0%, #005B9F 52%, #002847 100%);
box-shadow: 0 4px 20px rgba(0, 61, 107, 0.2), inset 0 0 24px rgba(0, 102, 204, 0.08);
color: #fff;
}
.wallet-hero-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.wallet-hero-brand {
display: flex;
align-items: center;
gap: 8px;
}
.wallet-hero-logo {
width: 22px;
height: 22px;
object-fit: contain;
}
.wallet-hero-brand-text {
font-size: 12px;
font-weight: 800;
font-family: var(--font-heading);
letter-spacing: 0.14em;
color: rgba(255, 255, 255, 0.88);
}
.wallet-hero-recharge {
display: inline-flex;
align-items: center;
padding: 8px 16px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.32);
background: rgba(255, 255, 255, 0.12);
color: #fff;
font-size: 13px;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
}
.wallet-hero-recharge:hover {
background: rgba(255, 255, 255, 0.2);
}
.wallet-hero-label {
display: block;
font-size: 11px;
font-weight: 700;
font-family: var(--font-heading);
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.72);
margin-bottom: var(--space-3);
}
.wallet-hero-balance {
margin: 0;
font-family: var(--font-mono);
font-size: clamp(30px, 3.2vw, 42px);
font-weight: 700;
line-height: 1.1;
}
.wallet-stat-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.wallet-stat-card {
padding: var(--space-5) var(--space-6);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--bg-card);
box-shadow: var(--shadow-xs);
transition: box-shadow var(--transition), transform var(--transition);
}
.wallet-stat-card:hover {
box-shadow: var(--shadow-sm), var(--glow-gold);
transform: translateY(-1px);
}
.wallet-stat-label {
display: block;
font-size: 11px;
font-weight: 700;
font-family: var(--font-heading);
color: var(--text-muted);
max-width: 420px;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--space-3);
}
.wallet-stat-value {
display: block;
font-size: 16px;
font-weight: 800;
font-family: var(--font-data);
color: var(--primary);
font-variant-numeric: tabular-nums;
word-break: break-all;
}
.wallet-panel {
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-card);
overflow: hidden;
box-shadow: var(--shadow-xs);
}
.wallet-panel-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
align-items: start;
}
.wallet-panel-row:last-child {
border-bottom: none;
}
.wallet-panel-row--actions {
display: flex;
justify-content: flex-end;
grid-template-columns: none;
}
.wallet-panel-label {
font-size: 12px;
font-weight: 800;
font-family: var(--font-heading);
color: var(--text-muted);
padding-top: 2px;
}
.wallet-panel-content {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.wallet-info-line {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
}
.wallet-info-key {
width: 64px;
flex-shrink: 0;
color: var(--text-muted);
font-weight: 600;
}
.wallet-info-val {
flex: 1;
font-weight: 700;
color: var(--text);
word-break: break-all;
}
.wallet-panel-link {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--border-active);
color: var(--primary-light);
font-size: 12px;
font-weight: 700;
text-decoration: none;
}
.wallet-panel-link:hover {
background: rgba(0, 102, 204, 0.06);
}
.wallet-lang-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.wallet-lang-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-body);
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.wallet-lang-btn.active {
border-color: var(--border-active);
color: var(--primary-light);
background: rgba(0, 102, 204, 0.08);
}
.wallet-logout-btn {
padding: 8px 18px;
border-radius: 8px;
border: 1px solid rgba(220, 38, 38, 0.35);
background: transparent;
color: var(--danger);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.wallet-logout-btn:hover {
background: rgba(220, 38, 38, 0.06);
}
@media (max-width: 720px) {
.wallet-stat-grid {
grid-template-columns: 1fr;
}
.wallet-panel-row {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>

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

@@ -0,0 +1,28 @@
import { existsSync, readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
/** 本地 dev 默认端口3000 在部分 WindowsHyper-V 保留 29603059上会 EACCES */
const DEFAULT_DEV_API_PORT = '3100';
/** 与 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 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()}`;
}