Compare commits

17 Commits

Author SHA1 Message Date
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
164 changed files with 11002 additions and 3967 deletions

255
AGENTS.md
View File

@@ -1,46 +1,225 @@
# AGENTS.md
## Runtime And Workspace
- Use Node 22+ and pnpm 11; `packageManager` pins `pnpm@11.5.2`.
- This is a pnpm workspace: `apps/api` is NestJS/Prisma, `apps/player` is Vue H5 on `:5173`, `apps/admin` is the unified platform-admin + agent Vue app on `:5174`, and `packages/shared` holds shared types/constants plus `public` assets.
- The frontends alias `@thebet365/shared` to `packages/shared/src/index.ts` because the built shared package is CommonJS; update shared source exports, not only `dist`.
- `packages/shared` build runs `scripts/generate-phone-countries.mjs` before `tsc`.
> 给 AI 编程助手Cursor 等)看的项目速查手册,浓缩开发约定与易错点。
> 人类日常开发请优先看 `README.md` 与 `docs/`。
## Local Setup
- Start local infrastructure with `docker compose up -d`; this exposes PostgreSQL on `5432` and Redis on `6379`.
- Copy env for the API as `cp .env.example apps/api/.env`; Nest reads `.env` from the API working directory when run via the workspace filter.
- First database setup from repo root: `pnpm db:generate`, then `pnpm db:migrate`, then `pnpm db:seed`.
- `pnpm dev:api` runs `scripts/ensure-port-free.mjs 3000` and will kill any listener on port `3000` before starting Nest watch.
- If starting apps separately, start API before `pnpm dev:player` or `pnpm dev:admin`; both Vite servers proxy `/api` to `http://localhost:3000`.
如果你是AI请每次读了这个文件就在已有的次数上+1
## Commands
- `pnpm dev` builds shared once, then runs all workspace `dev` scripts in parallel.
- `pnpm dev:admin` and `pnpm dev:manage` are the same admin app.
- Full verification is `pnpm build` and `pnpm test`; there is no root lint or formatter script in current manifests.
- API tests: `pnpm --filter @thebet365/api test`.
- Focused API Jest test: `pnpm --filter @thebet365/api exec jest settlement-calculator.spec.ts --runInBand`; avoid `pnpm ... test -- ...` for focused args because pnpm passes the literal `--` through to Jest here.
- Frontend typecheck/build: `pnpm --filter @thebet365/player build` and `pnpm --filter @thebet365/admin build`.
- Admin bundle report: `pnpm --filter @thebet365/admin build:analyze`.
当前被读次数: 1
## API Notes
- API URLs use the global `/api` prefix; Swagger is at `/api/docs` in non-production or when `ENABLE_SWAGGER` is truthy.
- `AppModule` installs a global `JwtAuthGuard`; public endpoints must use `@Public()` from `apps/api/src/shared/common/decorators.ts`.
- Keep business rules in `apps/api/src/domains/*`; `apps/api/src/applications/{player,admin,agent}` should stay as portal/controller orchestration.
- Wallet changes should go through ledger/wallet services; UAT docs explicitly forbid direct balance edits for settlement fixes.
- After editing `apps/api/prisma/schema.prisma`, run `pnpm db:generate`; use `pnpm db:migrate` for dev migrations and `pnpm db:migrate:deploy` for deployment.
## 目录
## Frontend Notes
- Edit `.ts` and `.vue` sources in `apps/player/src` and `apps/admin/src`; many `.js` siblings exist under `src`, but both `index.html` files load `/src/main.ts` and Vite resolution prefers TS before JS.
- Admin is one app for both `ADMIN` and `AGENT` accounts; route/menu gating is in `apps/admin/src/router/index.ts` and `apps/admin/src/stores/auth.ts`.
- Player is mobile-first; performance expectations and required mobile paths are in `docs/player-mobile-performance.md`.
## Tests And Smoke Checks
- Jest specs live under `apps/api/src/**/*.spec.ts` with `rootDir: src`; the documented unit/regression suite is rule-oriented and does not require a live DB.
- DB-backed smoke tests are exposed in the admin UI under `smoke-tests`; `SmokeTestService` allows them outside production, or in production only with `ALLOW_SMOKE_TESTS=true`.
- UAT regression flow is documented in `docs/UAT_CHECKLIST.md`; it includes admin UI smoke tests plus manual wallet/agent-credit checks.
| 章节 | 何时查 |
| ------------------------- | ----------------------- |
| [运行环境与仓库结构](#运行环境与仓库结构) | 不确定 monorepo 各 app 分工 |
| [本地启动](#本地启动) | 起 dev、数据库、端口 |
| [常用命令](#常用命令) | build / test / 打镜像 |
| [API 领域结构](#api-领域结构) | 业务代码该放哪个 domain |
| [API 约定](#api-约定) | 鉴权、钱包、迁移、赔率快照 |
| [前端约定](#前端约定) | 改 .vue/.ts、权限、dist |
| [i18n 改文案流程](#i18n-改文案流程) | 三语文案改哪几个文件 |
| [主题分支](#主题分支player-多皮肤) | theme-2/3/4 与 main 同步禁忌 |
| [演示账号](#演示账号开发-seed) | 本地登录测试号 |
| [文档索引](#文档索引) | 详细说明在 `docs/` 哪篇 |
| [测试与冒烟](#测试与冒烟) | Jest、UAT、smoke-tests |
| [部署注意](#部署注意) | 生产 compose、seed、打包 |
| [AI 改代码时请避免](#ai-改代码时请避免) | 常见踩坑清单 |
> **AI 会读吗?** Cursor 等工具会在对话开始时把 `AGENTS.md` 注入上下文;当前约 200 行,一般能整篇读入。文件继续变长时,索引有助于人和 AI 快速定位章节;细节仍以 `docs/` 为准,此处只写约定与指针。
## 运行环境与仓库结构
- 使用 Node 22+、pnpm 11`packageManager` 固定为 `pnpm@11.5.2`
- pnpm workspace`apps/api`NestJS/Prisma`apps/player`Vue H5`:5173`)、`apps/admin`(平台管理 + 代理合一,`:5174`)、`packages/shared`(共享类型/常量与 `public` 静态资源)。
- 前端将 `@thebet365/shared` 别名到 `packages/shared/src/index.ts`(构建产物为 CommonJS改 shared 时须更新源码导出,不要只改 `dist`
- `packages/shared` 构建前会执行 `scripts/generate-phone-countries.mjs`,再跑 `tsc`
- 本地开发 compose`docker-compose.yml`(仅 Postgres + Redis生产 compose`docker-compose.prod.yml` + `.env.docker`
## 本地启动
- 基础设施:`docker compose up -d`PostgreSQL `5432`、Redis `6379`)。
- API 环境变量:`cp .env.example apps/api/.env`;通过 workspace filter 启动时Nest 从 API 工作目录读 `.env`
- 首次数据库(仓库根目录):`pnpm db:generate``pnpm db:migrate``pnpm db:seed`
- `pnpm dev:api` 会先执行 `scripts/ensure-port-free.mjs 3000`,释放 3000 端口再启动 watch。
- 分应用启动时,**先 API**,再 `pnpm dev:player``pnpm dev:admin`;两个 Vite 都把 `/api` 代理到 `http://localhost:3000`
- Windows PowerShell 不支持 `&&` 链式命令,需分步执行或用 `;`
## 常用命令
- `pnpm dev`:先构建 shared再并行跑各 workspace 的 `dev`
- `pnpm dev:admin``pnpm dev:manage` 是同一个管理端应用。
- 全量校验:`pnpm build``pnpm test`;根目录**没有**统一的 lint/format 脚本。
- API 测试:`pnpm --filter @thebet365/api test`
- 单测文件:`pnpm --filter @thebet365/api exec jest settlement-calculator.spec.ts --runInBand`;不要用 `pnpm ... test -- ...` 传参pnpm 会把字面量 `--` 传给 Jest。
- 前端构建:`pnpm --filter @thebet365/player build``pnpm --filter @thebet365/admin build`
- 管理端体积分析:`pnpm --filter @thebet365/admin build:analyze`
- 本地镜像打包Windows`docs\docker\build-and-export-images.bat --tag latest`
- 生产更新(服务器):`./scripts/deploy-update.sh --images thebet365-images-<tag>.tar --tag <tag>`(详见 `docs/Docker部署指南.md` 第八节)。
## API 领域结构
业务规则放在 `apps/api/src/domains/*`,应用层只做编排:
| 领域 | 路径 | 职责 |
| -------------------------- | --------------------- | ------------- |
| identity | `domains/identity/` | 登录、用户、员工、RBAC |
| agent | `domains/agent/` | 代理网络、授信 |
| ledger | `domains/ledger/` | 钱包、账变 |
| catalog | `domains/catalog/` | 赛事 |
| odds | `domains/odds/` | 盘口 |
| betting | `domains/betting/` | 注单 |
| settlement | `domains/settlement/` | 结算 |
| operations | `domains/operations/` | 返水、内容、审计等 |
| player-messages / presence | 各子模块 | 站内信、在线状态 |
门户控制器:`applications/{player,admin,agent}/`
## API 约定
- 全局前缀 `/api`;非生产或 `ENABLE_SWAGGER=true` 时 Swagger 在 `/api/docs`
- `AppModule` 注册了全局 `JwtAuthGuard`;公开接口须加 `@Public()``apps/api/src/shared/common/decorators.ts`)。
- 业务规则放在 `domains/*``applications/*` 只做门户/控制器编排,不要写领域规则。
- 钱包变动走 ledger/wallet 服务UAT 明确禁止为修结算直接改余额。
- 下注赔率以提交时快照为准(`BetSelection.odds` + `oddsVersion`),结算用快照,不受后续盘口变动影响。
-`apps/api/prisma/schema.prisma` 后执行 `pnpm db:generate`;开发迁移 `pnpm db:migrate`,部署由 `deploy-update.sh` 执行 `prisma migrate deploy`
- 新增迁移后theme 分支若只改 player 样式,也须同步 API/Admin 与迁移文件。
## 前端约定
-`apps/player/src``apps/admin/src` 下的 `**.ts` / `.vue`**`src` 下虽有 `.js` 旁文件,但 `index.html` 入口是 `/src/main.ts`Vite 优先解析 TS。
- 管理端同一应用服务 `ADMIN``AGENT` 账号;路由/菜单权限在 `router/index.ts``stores/auth.ts`;员工可见菜单字段 `visibleMenus`
- 管理端权限常量:`apps/admin/src/constants/permissions.ts``AdminPerm`)。
- 玩家端移动优先;性能验收见 `docs/player-mobile-performance.md`
- 管理端切页慢的分析与优化任务见 `docs/admin-page-switch-performance.md`
- **不要**把 `apps/player/dist/` 提交进 Git生产由 Docker 构建生成静态资源。
## i18n 改文案流程
支持语言均为 **zh-CN / en-US / ms-MY**(马来语)。改文案须**三语同步**,不要只改中文。
### 玩家端(`apps/player`
| 项 | 说明 |
| ------ | ---------------------------------------------------------------- |
| 文案文件 | `apps/player/src/i18n/zh-CN.ts``en-US.ts``ms-MY.ts` |
| 结构 | **嵌套对象**(如 `bet.place_bet``nav.home` |
| 组件用法 | `useI18n()``t('bet.odds_changed')` |
| 语言切换 | `useAppLocale().setLocale()`;按需 `ensurePlayerLocale` 懒加载对应 ts 文件 |
| 存储 key | `localStorage.locale`;登录用户会 `POST /player/language` 同步后端 |
**改文案步骤:**
1. 在 Vue 里用 `t('模块.key')` 引用,**不要**在模板写死中文。
2.**三个** locale 文件的**相同路径**下各加一条(保持 key 一致)。
3. 带占位符用 vue-i18n 约定,如 `'共 {n} 场': '共 {n} 场'``t('key', { n: 3 })`
4. 本地 `pnpm dev:player` 切换语言各看一遍;热更新一般即时生效。
5. theme 分支合并 main 功能时:**只合并新增 key**,勿用 main 文件整文件覆盖theme 可能有不同措辞)。
### 管理端(`apps/admin`
| 项 | 说明 |
| --------- | ----------------------------------------------------------------------------------- |
| 核心入口 | `apps/admin/src/i18n/admin-messages.ts`(三语扁平 `Record<string, string>` |
| 列表/弹窗大段文案 | `admin-pages.ts`(中/英)、`admin-pages-ms.ts`(马来)→ 通过 spread 并入 `admin-messages` |
| 表单校验 key | `form-validation.ts``err.`* 等,用 `resolveFormError` 展示) |
| 组件用法 | `useAdminLocale().t('nav.bets')``t('key', { n: 1 })` 占位符 `{n}` |
| 语言切换 | `useAdminLocale().setLocale()``preloadAdminLocale` + `localStorage.admin_locale` |
| 动态拆包 | `bundles/{zh-CN,en-US,ms-MY}.ts` + `locale-loader.ts`(当前主包仍含三语全文,拆包未完全落地) |
**改文案步骤:**
1. 确定 key 命名:导航 `nav.`*、通用 `common.*`、页面 `user.*` / `match.*` / `deposit.*` 等,与现有前缀保持一致。
2.`admin-messages.ts` 的 **zh / en / ms 三个对象**里各加同名 key核心短文案
3. 若属于某列表页/弹窗长文案,优先加到 `admin-pages.ts`(中/英)与 `admin-pages-ms.ts`(马来)的对应 export它们会 spread 进 `admin-messages`
4. 表单校验错误throw `FormValidationError('err.xxx')` 并在三语里定义 `err.xxx`
5. 本地 `pnpm dev:admin`,右上角切换语言验证;改 `admin-pages`* 后若未刷新,重启 dev 一次。
6. Admin 与 Player **文案独立**,改玩家端不要只改 admin 文件(反之亦然)。
### 常见错误
- 只改 `zh-CN` 未改 `en-US``ms-MY` → 切换语言显示 key 或英文 fallback。
- 玩家端 key 层级不一致(如中文有 `bet.foo` 英文漏了 `bet` 层)→ `t()` 返回 key 字符串。
- 管理端在 `admin-pages.ts` 只加了 `adminPagesZh` 未加 `adminPagesEn` / `adminPagesMs`
- theme 分支用 `git checkout main -- apps/player/src/i18n/` 覆盖 → 冲掉主题分支已有翻译。
### 与业务/后台内容的关系
- **i18n 文件**:前端固定 UI 标签、按钮、提示。
- **管理端「公共管理」/ 公告 / Banner**:运营可配正文,走 API 与数据库,**不要**硬编码进 i18n玩家端公告详情等展示 API 返回内容)。
## 主题分支player 多皮肤)
| 分支 | 风格 | 注意 |
| --------- | ----------- | ------------------------------------------------------------------- |
| `main` | 暗金主题 | 生产默认发版分支 |
| `theme-2` | Pinnacle 蓝白 | 玩家 UI 与 main 分叉,**禁止** `git checkout main -- apps/player/...` 整文件覆盖 |
| `theme-3` | 统一移动端视觉 | 同 theme-2 |
| `theme-4` | 海军蓝暗色极简 | 含悬浮客服等独有组件 |
同步功能到 theme 分支API/Admin/迁移可整目录 checkoutPlayer 只做逻辑合并 + 保留各分支 `styles.css` 与主题资源。发版前在目标分支打包镜像。
## 演示账号(开发 seed
| 角色 | 用户名 | 密码 |
| ----- | ------- | ---------- |
| 超级管理员 | admin | Admin@123 |
| 一级代理 | agent1 | Agent@123 |
| 二级代理 | agent2 | Agent@123 |
| 玩家 | player1 | Player@123 |
生产 seed 仅创建 admin + WC2026 样例数据,不含代理/玩家演示号。详见 `docs/默认数据说明.md`
## 文档索引
| 文档 | 用途 |
| ------------------------------------------- | ------------------- |
| `docs/项目启动指南.md` | 本地开发、排错、端口 |
| `docs/Docker部署指南.md` | 生产部署、备份、回滚、发版流程 |
| `docs/docker/镜像构建与导出.md` | 本地打镜像 tar |
| `docs/默认数据说明.md` | seed 数据、WC2026、48 强 |
| `docs/投注玩法说明.md` | 玩法与判赢规则 |
| `docs/结算与返水金额规则.md` | 派彩/返水金额公式 |
| `docs/settlement-and-fund-flow-analysis.md` | 结算操作流程 |
| `docs/手动充值功能说明.md` | 充值审核流程 |
| `docs/短信调试与日志说明.md` | 创蓝短信排错 |
| `docs/UAT_CHECKLIST.md` | 上线前回归清单 |
| `docs/player-mobile-performance.md` | 玩家端性能验收 |
| `docs/admin-page-switch-performance.md` | 管理端切页优化任务 |
## 测试与冒烟
- Jest 用例在 `apps/api/src/**/*.spec.ts``rootDir: src`;文档中的单元/规则测试一般不依赖真实数据库。
- 管理端「冒烟测试」页调用 DB 相关检查;`SmokeTestService` 非生产默认可用,生产需 `ALLOW_SMOKE_TESTS=true`
- UAT 回归流程见 `docs/UAT_CHECKLIST.md`(含管理端 UI 冒烟与钱包/代理额度手工检查)。
## 部署注意
- 生产 compose 使用 `.env.docker``docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build`,或配置好后 `pnpm docker:up`
- **不要**用 example 覆盖线上 `.env.docker`;部署脚本通常只回写 `IMAGE_TAG`
- 生产首次 seed 后保持 `SEED_DATABASE=false`;迁移由 `deploy-update.sh` 执行,勿在 API 容器设 `RUN_MIGRATIONS_ON_START=true`(除非应急)。
- `scripts/prod-init-db.sh` **会清空业务数据**:须 `CONFIRM=YES`,默认先备份,再 truncate 并灌生产数据。
- 打包部署 zip`pnpm pack:deploy``pack.mjs` 会删除 `packages/shared/public` 下除 `flags``players` 外的遗留中文目录。
- 构建 player/admin 若报 `ENOENT ... public/球员`:清理 `packages/shared/public` 下错误中文目录后重试。
## AI 改代码时请避免
- 不要 `git merge main` 进 theme 分支做 player 同步(会把 main 暗金样式大量带入)。
- 不要直接改数据库余额修结算;走结算/钱包服务。
- 不要提交 `.env``.env.docker`、镜像 tar、`apps/*/dist/`
- 改 player 主题相关文件时,先确认当前分支是 `main` 还是 `theme-`*,避免用错 CSS 变量(如 theme-2 无 `--gold`)。
- 管理端改列表页性能时,参考 `docs/admin-page-switch-performance.md` 任务清单,优先 KeepAlive + 缓存而非盲目减 API 字段。
## Deployment Gotchas
- Production compose uses `.env.docker`: `docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --build` or `pnpm docker:up` after creating `.env.docker`.
- In production, keep `SEED_DATABASE=false` after first seed; production seed creates only `admin` plus WC2026 sample data, while dev seed includes agent/player demo accounts.
- `scripts/prod-init-db.sh` is destructive by design: it requires `CONFIRM=YES`, backs up unless `--skip-backup`, truncates business data, then seeds production data.
- Deployment packaging is `pnpm pack:deploy`; `pack.mjs` removes legacy shared public directories except `flags` and `players` before creating `release/thebet365-deploy-*.zip`.

10
apps/admin/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

85
apps/admin/components.d.ts vendored Normal file
View File

@@ -0,0 +1,85 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AdminAgentRowActions: typeof import('./src/components/AdminAgentRowActions.vue')['default']
AdminDetailGrid: typeof import('./src/components/AdminDetailGrid.vue')['default']
AdminDetailItem: typeof import('./src/components/AdminDetailItem.vue')['default']
AdminLocaleFlag: typeof import('./src/components/AdminLocaleFlag.vue')['default']
AdminLocaleSwitcher: typeof import('./src/components/AdminLocaleSwitcher.vue')['default']
AdminNavIcon: typeof import('./src/components/AdminNavIcon.vue')['default']
AdminPlayerRowActions: typeof import('./src/components/AdminPlayerRowActions.vue')['default']
AdminPlayerStatusCell: typeof import('./src/components/AdminPlayerStatusCell.vue')['default']
AdminResponsiveRowActions: typeof import('./src/components/AdminResponsiveRowActions.vue')['default']
AdminRowActionsDropdown: typeof import('./src/components/AdminRowActionsDropdown.vue')['default']
AdminSubNav: typeof import('./src/components/AdminSubNav.vue')['default']
AdminTableEmpty: typeof import('./src/components/AdminTableEmpty.vue')['default']
AdminTableWrap: typeof import('./src/components/AdminTableWrap.vue')['default']
AgentCreditContext: typeof import('./src/components/AgentCreditContext.vue')['default']
AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default']
ContentImageField: typeof import('./src/components/ContentImageField.vue')['default']
ContentRichEditor: typeof import('./src/components/ContentRichEditor.vue')['default']
CountryFlagSelect: typeof import('./src/components/outright/CountryFlagSelect.vue')['default']
DashboardSubNav: typeof import('./src/components/DashboardSubNav.vue')['default']
EChartPanel: typeof import('./src/components/dashboard/EChartPanel.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCol: typeof import('element-plus/es')['ElCol']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
InitialDepositRemarkField: typeof import('./src/components/InitialDepositRemarkField.vue')['default']
InviteCodePanel: typeof import('./src/components/InviteCodePanel.vue')['default']
InviteHistoryPanel: typeof import('./src/components/InviteHistoryPanel.vue')['default']
InviteManageDialog: typeof import('./src/components/InviteManageDialog.vue')['default']
LeagueArchiveDialog: typeof import('./src/components/LeagueArchiveDialog.vue')['default']
LogoUrlField: typeof import('./src/components/LogoUrlField.vue')['default']
MatchArchiveDialog: typeof import('./src/components/MatchArchiveDialog.vue')['default']
MatchesSubNav: typeof import('./src/components/MatchesSubNav.vue')['default']
PlayerWalletLedgerDialog: typeof import('./src/components/PlayerWalletLedgerDialog.vue')['default']
RatePercentInput: typeof import('./src/components/RatePercentInput.vue')['default']
RobotVerify: typeof import('./src/components/RobotVerify.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
WalletTransferContext: typeof import('./src/components/WalletTransferContext.vue')['default']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -25,6 +25,8 @@
"@vitejs/plugin-vue": "^5.2.1",
"rollup-plugin-visualizer": "^7.0.1",
"typescript": "^5.7.3",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^32.1.0",
"vite": "^6.0.11",
"vue-tsc": "^2.2.0"
}

View File

@@ -43,96 +43,10 @@ a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; }
:root {
/* Monochrome admin — 克制、无渐变、无品牌色泛滥 */
--accent: #f5f5f5;
--accent-muted: #a3a3a3;
--accent-dim: #737373;
--accent-subtle: rgba(255, 255, 255, 0.06);
--accent-border: rgba(255, 255, 255, 0.1);
--accent-hover: rgba(255, 255, 255, 0.04);
--accent-focus: rgba(255, 255, 255, 0.14);
/* 语义色:仅 success/warning/danger 场景使用 */
--success-text: #7d9b8a;
--success-bg: rgba(255, 255, 255, 0.04);
--success-border: rgba(255, 255, 255, 0.08);
/* 兼容旧变量名 → 中性灰白 */
--gold-deep: var(--accent-dim);
--gold-mid: var(--accent);
--gold-bright: var(--accent);
--gold-glow: var(--accent);
--gold-surface: var(--accent-subtle);
--gold-border: var(--accent-border);
--gold-text: var(--accent-muted);
--green-deep: var(--accent-dim);
--green-mid: var(--accent);
--green-bright: var(--accent);
--green-glow: var(--accent);
--green-surface: var(--accent-subtle);
--green-border: var(--accent-border);
--green-text: var(--accent-muted);
--primary: var(--accent);
--primary-dark: #e5e5e5;
--primary-light: #ffffff;
--primary-link: var(--accent-muted);
--primary-on: #0a0a0a;
--primary-grad: var(--accent);
--primary-grad-hover: #ffffff;
--primary-shadow: none;
--bg-body: #0a0a0a;
--bg-card: #111111;
--bg-elevated: #161616;
--bg-hover: var(--accent-hover);
--text: #f5f5f5;
--text-muted: #737373;
--border: rgba(255, 255, 255, 0.08);
--border-soft: var(--accent-border);
--radius: 8px;
--radius-sm: 6px;
--shadow: none;
/* Element Plus dark overrides */
--el-bg-color: #111111;
--el-bg-color-page: #0a0a0a;
--el-bg-color-overlay: #161616;
--el-text-color-primary: #f5f5f5;
--el-text-color-regular: #d4d4d4;
--el-text-color-secondary: #737373;
--el-text-color-placeholder:#525252;
--el-border-color: rgba(255, 255, 255, 0.08);
--el-border-color-light: rgba(255, 255, 255, 0.06);
--el-border-color-lighter: rgba(255, 255, 255, 0.04);
--el-fill-color: #141414;
--el-fill-color-blank: #0a0a0a;
--el-fill-color-light: #111111;
--el-color-primary: #e5e5e5;
--el-color-primary-light-3: rgba(255, 255, 255, 0.22);
--el-color-primary-light-5: rgba(255, 255, 255, 0.12);
--el-color-primary-light-7: rgba(255, 255, 255, 0.07);
--el-color-primary-light-9: rgba(255, 255, 255, 0.04);
--el-color-primary-dark-2: #a3a3a3;
--el-color-success: #7d9b8a;
--el-color-success-light-3: rgba(125, 155, 138, 0.22);
--el-color-success-light-5: rgba(125, 155, 138, 0.12);
--el-color-success-light-7: rgba(125, 155, 138, 0.07);
--el-color-success-light-9: rgba(125, 155, 138, 0.04);
--el-color-success-dark-2: #5c7568;
--el-table-bg-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: rgba(255, 255, 255, 0.03);
--el-table-border-color: rgba(255, 255, 255, 0.06);
--el-table-text-color: #d4d4d4;
--el-table-header-text-color: #737373;
--el-card-bg-color: #111111;
--el-card-border-color: rgba(255, 255, 255, 0.08);
/* Common variables placeholder if needed, layout rules follow */
}
html, body, #app {
height: 100%;
overflow: hidden;
}
/* 页面级隐藏滚动条;可滚动区域保留细滚动条便于发现溢出内容 */
/* 页面级隐藏滚动条 */
html, body {
scrollbar-width: none;
-ms-overflow-style: none;
@@ -143,33 +57,8 @@ body::-webkit-scrollbar {
height: 0;
display: none;
}
.admin-list-page .table-wrap,
.dashboard-page,
.page-scroll,
.settlement-page,
.nav {
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
}
.admin-list-page .table-wrap::-webkit-scrollbar,
.dashboard-page::-webkit-scrollbar,
.page-scroll::-webkit-scrollbar,
.settlement-page::-webkit-scrollbar,
.nav::-webkit-scrollbar {
width: 6px;
height: 6px;
display: block;
}
.admin-list-page .table-wrap::-webkit-scrollbar-thumb,
.dashboard-page::-webkit-scrollbar-thumb,
.page-scroll::-webkit-scrollbar-thumb,
.settlement-page::-webkit-scrollbar-thumb,
.nav::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.16);
border-radius: 3px;
}
/* 管理端列表页:占满主区域,表头固定、表体滚动,底部分页 */
/* 管理端列表页布局 */
.admin-list-page {
display: flex;
flex-direction: column;
@@ -211,8 +100,8 @@ body::-webkit-scrollbar {
flex-wrap: nowrap;
padding: 8px 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
background: var(--bg-hover);
border: 1px solid var(--border-soft);
--list-chrome-control-h: 32px;
--el-component-size: 32px;
}
@@ -323,11 +212,11 @@ body::-webkit-scrollbar {
padding: 0 10px;
font-size: 13px;
font-weight: 600;
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.05);
background: var(--bg-hover);
border-color: var(--border-soft);
}
.list-settings :deep(.el-collapse-item__wrap) {
border-color: rgba(255, 255, 255, 0.05);
border-color: var(--border-soft);
}
.list-settings :deep(.el-collapse-item__content) {
padding: 8px 10px 10px;
@@ -335,12 +224,12 @@ body::-webkit-scrollbar {
.list-settings-block + .list-settings-block {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.05);
border-top: 1px solid var(--border-soft);
}
.list-settings-title {
font-size: 12px;
font-weight: 600;
color: #aaa;
color: var(--text-muted);
margin-bottom: 6px;
}
.admin-list-page > .data-card,
@@ -388,7 +277,7 @@ body::-webkit-scrollbar {
margin: 0;
padding: 6px 0 8px;
font-size: 11px;
color: #666;
color: var(--text-muted);
line-height: 1.4;
}
.admin-list-page .pager {
@@ -399,7 +288,7 @@ body::-webkit-scrollbar {
padding-top: 0;
}
/* 控制台等非列表页:允许内部滚动(滚动条已全局隐藏) */
/* 控制台等非列表页 */
.dashboard-page,
.page-scroll {
height: 100%;
@@ -416,350 +305,12 @@ body {
-moz-osx-font-smoothing: grayscale;
}
/* ── Element Plus 全局暗色覆盖 ── */
.el-card {
background: var(--bg-card) !important;
border-color: var(--border) !important;
border-radius: var(--radius) !important;
box-shadow: none !important;
}
.el-card__header {
border-bottom-color: var(--border) !important;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.el-table { background: transparent !important; color: #ccc !important; }
.el-table::before { background-color: #222 !important; }
.el-table th.el-table__cell {
background: transparent !important;
color: var(--text-muted) !important;
font-size: 12px; font-weight: 500;
letter-spacing: 0; text-transform: none;
border-bottom-color: var(--border) !important;
}
.el-table td.el-table__cell { border-bottom-color: rgba(255, 255, 255, 0.04) !important; color: #d4d4d4 !important; }
.el-table--striped .el-table__body tr.el-table__row--striped td { background: rgba(255,255,255,0.015) !important; }
.el-table__body tr:hover > td { background: rgba(255, 255, 255, 0.03) !important; }
.el-input__wrapper {
background: var(--bg-body) !important;
box-shadow: 0 0 0 1px var(--border) inset !important;
border-radius: var(--radius-sm) !important;
}
.el-input__wrapper:hover { box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14) inset !important; }
.el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.28) inset !important;
}
.el-input__inner { color: #fff !important; background: transparent !important; }
.el-input__inner:-webkit-autofill,
.el-input__inner:-webkit-autofill:focus {
-webkit-box-shadow: 0 0 0 1000px #0d0d0d inset !important;
-webkit-text-fill-color: #fff !important;
}
.el-button { background: transparent !important; border-color: var(--border) !important; color: #a3a3a3 !important; font-weight: 500 !important; transition: background 0.15s, border-color 0.15s, color 0.15s !important; }
.el-button:hover { background: var(--accent-hover) !important; border-color: rgba(255, 255, 255, 0.14) !important; color: #f5f5f5 !important; }
.el-button--primary {
background: var(--accent) !important;
border: 1px solid var(--accent) !important;
color: var(--primary-on) !important;
font-weight: 500 !important;
box-shadow: none !important;
}
.el-button--primary:hover {
background: #ffffff !important;
border-color: #ffffff !important;
color: var(--primary-on) !important;
box-shadow: none !important;
}
.el-button--success {
background: transparent !important;
border: 1px solid var(--success-border) !important;
color: var(--success-text) !important;
font-weight: 500 !important;
box-shadow: none !important;
}
.el-button--success:hover {
background: var(--success-bg) !important;
border-color: rgba(255, 255, 255, 0.12) !important;
color: #a8c4b4 !important;
box-shadow: none !important;
}
.el-button--warning {
background: transparent !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: #d4a574 !important;
font-weight: 500 !important;
box-shadow: none !important;
}
.el-button--warning:hover {
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: #e0b888 !important;
box-shadow: none !important;
}
.el-button--danger { background: transparent !important; border-color: rgba(255,69,58,0.25) !important; color: #ef4444 !important; }
/* ── Disabled: muted ghost, clearly non-interactive ── */
.el-button.is-disabled,
.el-button.is-disabled:hover,
.el-button.is-disabled:focus,
.el-button:disabled {
cursor: not-allowed !important;
pointer-events: none;
transform: none !important;
filter: none;
box-shadow: none !important;
text-shadow: none !important;
opacity: 1 !important;
}
.el-button.is-disabled,
.el-button.is-disabled:hover,
.el-button:disabled {
background: rgba(255, 255, 255, 0.04) !important;
border-color: rgba(255, 255, 255, 0.08) !important;
color: rgba(255, 255, 255, 0.28) !important;
}
.el-button--primary.is-disabled,
.el-button--primary.is-disabled:hover,
.el-button--primary:disabled {
background: rgba(255, 255, 255, 0.06) !important;
border-color: rgba(255, 255, 255, 0.06) !important;
color: rgba(255, 255, 255, 0.28) !important;
}
.el-button--primary.is-plain.is-disabled,
.el-button--primary.is-plain.is-disabled:hover,
.el-button--primary.is-plain:disabled {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.06) !important;
color: rgba(255, 255, 255, 0.24) !important;
}
.el-button--success.is-disabled,
.el-button--success.is-disabled:hover,
.el-button--success:disabled {
background: transparent !important;
border-color: rgba(255, 255, 255, 0.06) !important;
color: rgba(255, 255, 255, 0.24) !important;
}
.el-button--warning.is-disabled,
.el-button--warning.is-disabled:hover,
.el-button--warning:disabled {
background: rgba(196, 132, 18, 0.1) !important;
border-color: rgba(196, 132, 18, 0.14) !important;
color: rgba(251, 191, 36, 0.28) !important;
}
.el-button--danger.is-plain.is-disabled,
.el-button--danger.is-plain.is-disabled:hover,
.el-button--danger.is-plain:disabled {
background: rgba(255, 69, 58, 0.06) !important;
border-color: rgba(255, 69, 58, 0.1) !important;
color: rgba(255, 107, 98, 0.28) !important;
}
.el-button--danger.is-disabled,
.el-button--danger.is-disabled:hover,
.el-button--danger:disabled {
background: rgba(255, 69, 58, 0.06) !important;
border-color: rgba(255, 69, 58, 0.1) !important;
color: rgba(255, 107, 98, 0.28) !important;
}
.el-button--primary.is-plain {
background: transparent !important;
border-color: var(--border) !important;
color: #d4d4d4 !important;
box-shadow: none !important;
}
.el-button--primary.is-plain:hover {
background: var(--accent-hover) !important;
border-color: rgba(255, 255, 255, 0.14) !important;
color: #f5f5f5 !important;
}
.el-button--danger.is-plain {
background: rgba(255, 69, 58, 0.14) !important;
border-color: rgba(255, 69, 58, 0.45) !important;
color: #ff6b62 !important;
font-weight: 600 !important;
box-shadow: none !important;
}
.el-button--danger.is-plain:hover {
background: rgba(255, 69, 58, 0.24) !important;
border-color: rgba(255, 120, 110, 0.55) !important;
color: #ff9a92 !important;
}
.el-button.is-text,
.el-button.is-link.el-button--default {
color: var(--accent-muted) !important;
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
.el-button.is-text:hover,
.el-button.is-link.el-button--default:hover {
color: #f5f5f5 !important;
background: transparent !important;
}
.el-tag { border-radius: 4px !important; font-size: 11px !important; font-weight: 500 !important; }
.el-tag--success {
background: var(--success-bg) !important;
border: 1px solid var(--success-border) !important;
color: var(--success-text) !important;
}
.el-tag--warning { background: rgba(251,191,36,0.1) !important; border-color: rgba(251,191,36,0.3) !important; color: #fbbf24 !important; }
.el-tag--danger { background: rgba(255,69,58,0.1) !important; border-color: rgba(255,69,58,0.3) !important; color: #ff453a !important; }
.el-tag--info { background: rgba(255,255,255,0.06) !important; border-color: #3a3a3a !important; color: #aaa !important; }
/* 表格操作:纯文字链 */
.el-button.is-link.el-button--primary,
.el-button.is-link.el-button--success {
color: var(--accent-muted) !important;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
padding: 0 4px !important;
box-shadow: none !important;
font-weight: 500 !important;
}
.el-button.is-link.el-button--primary:hover,
.el-button.is-link.el-button--primary:focus,
.el-button.is-link.el-button--success:hover,
.el-button.is-link.el-button--success:focus {
color: #f5f5f5 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
text-decoration: underline;
text-underline-offset: 3px;
}
.el-button.is-link.el-button--warning {
color: #a3a3a3 !important;
background: transparent !important;
border: none !important;
border-radius: 0 !important;
padding: 0 4px !important;
box-shadow: none !important;
font-weight: 500 !important;
}
.el-button.is-link.el-button--warning:hover,
.el-button.is-link.el-button--warning:focus {
color: #d4a574 !important;
background: transparent !important;
border: none !important;
box-shadow: none !important;
text-decoration: underline;
text-underline-offset: 3px;
}
/* 表格 link 操作按钮禁用:保留按钮形态,灰显不可点 */
.el-button.is-link.is-disabled,
.el-button.is-link.is-disabled:hover,
.el-button.is-link.is-disabled:focus,
.el-button.is-link:disabled {
cursor: not-allowed !important;
pointer-events: none;
background: rgba(255, 255, 255, 0.04) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: rgba(255, 255, 255, 0.28) !important;
box-shadow: none !important;
text-shadow: none !important;
opacity: 1 !important;
transform: none !important;
filter: none;
}
.el-button.is-link.el-button--primary.is-disabled,
.el-button.is-link.el-button--success.is-disabled {
background: transparent !important;
border: none !important;
color: rgba(255, 255, 255, 0.24) !important;
text-decoration: none !important;
}
.el-button.is-link.el-button--danger.is-disabled {
background: rgba(255, 69, 58, 0.06) !important;
border-color: rgba(255, 69, 58, 0.1) !important;
color: rgba(255, 107, 98, 0.26) !important;
}
.el-button.is-link.el-button--warning.is-disabled {
background: rgba(196, 132, 18, 0.08) !important;
border-color: rgba(196, 132, 18, 0.12) !important;
color: rgba(251, 191, 36, 0.26) !important;
}
.el-form-item__label { color: var(--text-muted) !important; font-size: 13px !important; font-weight: 500 !important; letter-spacing: 0 !important; }
/* ── Dialog / overlay实心背景避免噪点透底发糊 ── */
.el-overlay {
background-color: rgba(0, 0, 0, 0.72) !important;
backdrop-filter: none !important;
}
.el-dialog {
background: #141414 !important;
border: 1px solid var(--border) !important;
border-radius: var(--radius) !important;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5) !important;
}
.el-dialog__header {
border-bottom: 1px solid #2a2a2a !important;
padding: 16px 20px 14px !important;
margin-right: 0 !important;
}
.el-dialog__title {
font-size: 16px !important;
font-weight: 700 !important;
color: #f0f0f0 !important;
letter-spacing: 0.02em !important;
}
.el-dialog__headerbtn .el-dialog__close {
color: #888 !important;
}
.el-dialog__headerbtn:hover .el-dialog__close {
color: #fff !important;
}
.el-dialog__body {
padding: 18px 20px !important;
font-size: 14px !important;
color: #ddd !important;
}
.el-dialog__footer {
border-top: 1px solid #2a2a2a !important;
padding: 12px 20px 16px !important;
}
.el-dialog .el-form-item__label {
font-size: 13px !important;
color: #aaa !important;
}
.el-dialog .el-descriptions__label {
color: #888 !important;
font-size: 12px !important;
font-weight: 600 !important;
background: #141414 !important;
}
.el-dialog .el-descriptions__content {
color: #e0e0e0 !important;
font-size: 13px !important;
background: #1a1a1a !important;
}
.el-dialog .el-descriptions__cell {
border-color: #2a2a2a !important;
}
/* 详情/编辑弹窗自定义布局 */
.user-edit-dialog .el-dialog__body,
.agent-edit-dialog .el-dialog__body {
max-height: min(70vh, 640px);
overflow-y: auto;
}
.user-edit-dialog .edit-meta,
.agent-edit-dialog .edit-meta {
display: flex;
@@ -767,7 +318,7 @@ body {
justify-content: space-between;
margin-bottom: 12px;
font-size: 12px;
color: #888;
color: var(--text-muted);
}
.user-edit-dialog .edit-form-section,
.agent-edit-dialog .edit-form-section {
@@ -781,7 +332,7 @@ body {
.agent-edit-dialog .section-title {
font-size: 11px;
font-weight: 700;
color: #888;
color: var(--text-muted);
letter-spacing: 0.04em;
margin-bottom: 8px;
}
@@ -792,7 +343,7 @@ body {
.user-edit-dialog .field-hint,
.agent-edit-dialog .field-hint {
font-size: 12px;
color: #888;
color: var(--text-muted);
margin-top: 4px;
line-height: 1.4;
}
@@ -804,15 +355,15 @@ body {
.agent-edit-dialog .password-mgmt-block {
margin: 0;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid var(--border-soft);
border-radius: 8px;
background: rgba(0, 0, 0, 0.15);
background: var(--bg-hover);
}
.user-edit-dialog .block-title,
.agent-edit-dialog .block-title {
font-size: 12px;
font-weight: 700;
color: #e8a84a;
color: var(--warning-text);
margin-bottom: 8px;
letter-spacing: 0.04em;
}
@@ -831,7 +382,7 @@ body {
.user-edit-dialog .password-field-label,
.agent-edit-dialog .password-field-label {
font-size: 12px;
color: #888;
color: var(--text-muted);
line-height: 1.35;
}
.user-edit-dialog .password-plain,
@@ -839,12 +390,12 @@ body {
font-family: ui-monospace, monospace;
font-size: 14px;
font-weight: 600;
color: #f0d090;
color: var(--warning-text);
letter-spacing: 0.06em;
}
.user-edit-dialog .password-empty,
.agent-edit-dialog .password-empty {
color: #666;
color: var(--text-muted);
}
.user-edit-dialog .block-hint,
.agent-edit-dialog .block-hint {
@@ -870,43 +421,22 @@ body {
.agent-edit-dialog .edit-stats-panel {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
border-top: 1px solid var(--border-soft);
}
.agent-edit-dialog .edit-stats {
margin-top: 4px;
}
.entity-detail-dialog .el-dialog__body {
padding: 12px 20px 16px !important;
max-height: none !important;
overflow: visible !important;
}
.detail-actions {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
border-top: 1px solid var(--border-soft);
}
.el-statistic__head { color: #737373 !important; font-size: 12px !important; font-weight: 500 !important; letter-spacing: 0 !important; text-transform: none !important; }
.el-statistic__content .el-statistic__number { font-size: 24px !important; font-weight: 500 !important; color: #f5f5f5 !important; }
.el-input-number .el-input__wrapper { background: #0d0d0d !important; }
.el-date-editor .el-input__wrapper { background: #0d0d0d !important; }
.el-date-editor .el-input__inner { color: #fff !important; }
.el-picker-panel {
background: #1c1c1c !important;
border-color: #333 !important;
color: #ddd !important;
}
.el-picker-panel__footer { background: #1c1c1c !important; border-top-color: #333 !important; }
.el-date-picker__header-label,
.el-date-table th,
.el-date-table td .el-date-table-cell__text { color: #ccc !important; }
.el-time-panel { background: #1c1c1c !important; border-color: #333 !important; }
.el-time-spinner__item { color: #aaa !important; }
.el-time-spinner__item.is-active:not(.is-disabled) { color: #fff !important; }
/* ──────────────────────────────────────────────────────────────
Admin light refresh
The legacy admin theme was built as one dark surface. These
@@ -1153,6 +683,12 @@ a {
color: var(--text) !important;
}
.el-input,
.el-select,
.el-date-editor {
height: auto !important;
}
.el-input__wrapper:hover,
.el-select__wrapper:hover,
.el-textarea__inner:hover {
@@ -1426,6 +962,40 @@ input:-webkit-autofill:focus {
color: var(--warning-text) !important;
}
/* Link buttons hover in light theme - override dark legacy */
.el-button.is-link:hover,
.el-button.is-link:focus,
.el-button.is-link.el-button--default:hover,
.el-button.is-link.el-button--default:focus,
.el-button.is-link.el-button--primary:hover,
.el-button.is-link.el-button--primary:focus {
color: #155b86 !important; /* Darker blue */
background: transparent !important;
text-decoration: underline !important;
}
.el-button.is-link.el-button--success:hover,
.el-button.is-link.el-button--success:focus {
color: #224225 !important; /* Darker green */
background: transparent !important;
text-decoration: underline !important;
}
.el-button.is-link.el-button--warning:hover,
.el-button.is-link.el-button--warning:focus {
color: #704b00 !important; /* Darker warning/gold */
background: transparent !important;
text-decoration: underline !important;
}
.el-button.is-link.el-button--danger:hover,
.el-button.is-link.el-button--danger:focus {
color: #7d2321 !important; /* Darker danger/red */
background: transparent !important;
text-decoration: underline !important;
}
.el-button.is-disabled,
.el-button.is-disabled:hover {
background: #f4f0e8 !important;

View File

@@ -44,6 +44,8 @@ async function redirectToLoginForInvalidSession() {
}
}
let _lastReconciledToken: string | null = null;
api.interceptors.request.use((config) => {
const t = localStorage.getItem('manage_token');
if (t) config.headers.Authorization = `Bearer ${t}`;
@@ -51,7 +53,11 @@ api.interceptors.request.use((config) => {
const locale = localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY) || 'zh-CN';
config.headers['X-Locale'] = locale;
reconcileStaffSessionFromToken();
// 只在 token 变更时才重新 decode JWT + reconcile避免并发请求时多次执行同步开销
if (t !== _lastReconciledToken) {
reconcileStaffSessionFromToken();
_lastReconciledToken = t;
}
const auth = useAuthStore();
const path = requestPath(config);

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
import { useAdminLocale } from '../composables/useAdminLocale';
withDefaults(
defineProps<{
status: string;
isOnline?: boolean;
}>(),
{ isOnline: false },
);
const { t } = useAdminLocale();
function statusTagType(s: string) {
return s === 'ACTIVE' ? 'success' : s === 'SUSPENDED' ? 'warning' : 'info';
}
function statusLabel(s: string) {
const key = `user.status.${s}`;
const label = t(key);
return label !== key ? label : s;
}
</script>
<template>
<span class="player-status-with-presence">
<el-tag :type="statusTagType(status)" size="small">{{ statusLabel(status) }}</el-tag>
<span :class="isOnline ? 'presence-online' : 'presence-offline'">
({{ t(isOnline ? 'user.presence_online' : 'user.presence_offline') }})
</span>
</span>
</template>
<style scoped>
.player-status-with-presence {
display: inline-flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.presence-online {
font-size: 12px;
color: #2d8a4e;
font-weight: 600;
}
.presence-offline {
font-size: 12px;
color: #999;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, onActivated, watch } from 'vue';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuditLabels } from '../utils/audit-labels';
import api from '../api';
@@ -37,10 +37,14 @@ interface AuditRow {
const logs = ref<AuditRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const pageSize = ref(10);
const filterModule = ref('');
const loading = ref(false);
onMounted(load);
onMounted(() => void load());
onActivated(() => {
if (logs.value.length > 0) void load({ silent: true });
});
watch(
() => props.endpoint,
@@ -50,7 +54,9 @@ watch(
},
);
async function load() {
async function load(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const { data } = await api.get(props.endpoint, {
params: {
page: page.value,
@@ -62,6 +68,9 @@ async function load() {
});
logs.value = (data.data.items ?? []) as AuditRow[];
total.value = data.data.total ?? 0;
} finally {
if (!opts?.silent) loading.value = false;
}
}
function onPageChange(p: number) {
@@ -123,12 +132,13 @@ function operatorDisplay(row: AuditRow): string {
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<el-card v-loading="loading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="locale" :data="logs" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" fixed="left" />
<el-table-column :label="t('audit.col.time')" min-width="168" fixed="left">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>

View File

@@ -0,0 +1,316 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const props = withDefaults(
defineProps<{
modelValue: string;
category?: string;
sizeHintKey?: string;
disabled?: boolean;
}>(),
{ category: 'contents', sizeHintKey: 'content.upload.cover_size_hint' },
);
const emit = defineEmits<{ 'update:modelValue': [string] }>();
const { t } = useAdminLocale();
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
const uploading = ref(false);
const mediaPickerVisible = ref(false);
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string }>>([]);
const mediaLoading = ref(false);
async function uploadImage(file: File) {
if (file.size > MAX_UPLOAD_SIZE) {
ElMessage.error(t('content.upload.size_error'));
return;
}
uploading.value = true;
try {
const fd = new FormData();
fd.append('file', file);
const { data } = await api.post(`/admin/uploads?category=${props.category}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const url = data.data?.url as string;
if (url) {
emit('update:modelValue', url);
ElMessage.success(t('content.upload.success'));
}
} catch (err: unknown) {
const e = err as { response?: { data?: { message?: string } } };
ElMessage.error(String(e.response?.data?.message || t('content.upload.failed')));
} finally {
uploading.value = false;
}
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.[0]) {
void uploadImage(input.files[0]);
input.value = '';
}
}
function removeImage() {
emit('update:modelValue', '');
}
async function openMediaPicker() {
mediaPickerVisible.value = true;
mediaLoading.value = true;
try {
const res = await api.get('/admin/files', {
params: { category: props.category, pageSize: 200 },
});
mediaFiles.value = res.data.data.items ?? [];
} catch {
mediaFiles.value = [];
} finally {
mediaLoading.value = false;
}
}
function pickMediaFile(url: string) {
emit('update:modelValue', url);
mediaPickerVisible.value = false;
}
</script>
<template>
<div class="content-image-field">
<div class="field-main">
<div v-if="modelValue" class="preview">
<img :src="modelValue" alt="" class="preview-img" />
<button
type="button"
class="preview-remove"
:title="t('content.upload.remove')"
:disabled="disabled || uploading"
@click="removeImage"
>
&times;
</button>
</div>
<div class="field-controls">
<p v-if="sizeHintKey" class="size-hint">{{ t(sizeHintKey) }}</p>
<div class="actions">
<label class="upload-btn" :class="{ 'is-uploading': uploading, 'is-disabled': disabled }">
<input
type="file"
:accept="IMAGE_ACCEPT"
style="display: none"
:disabled="disabled || uploading"
@change="onFileChange"
/>
{{ uploading ? t('content.upload.uploading') : t('content.upload.upload_btn') }}
</label>
<button type="button" class="pick-btn" :disabled="disabled || uploading" @click="openMediaPicker">
{{ t('content.upload.pick_media') }}
</button>
</div>
</div>
</div>
<el-input
:model-value="modelValue"
:placeholder="t('content.upload.url_placeholder')"
size="small"
:disabled="disabled"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
<el-dialog
v-model="mediaPickerVisible"
:title="t('content.upload.pick_media_title')"
width="680px"
destroy-on-close
append-to-body
>
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
<div v-else class="media-grid">
<div
v-for="file in mediaFiles"
:key="file.id"
class="media-card"
@click="pickMediaFile(file.url)"
>
<div class="media-thumb">
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
<div v-else class="media-svg">SVG</div>
</div>
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
</div>
</div>
</el-dialog>
</template>
<style scoped>
.content-image-field {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.field-main {
display: flex;
align-items: flex-start;
gap: 10px;
}
.field-controls {
flex: 1;
min-width: 0;
}
.size-hint {
margin: 0 0 4px;
font-size: 11px;
line-height: 1.35;
color: var(--text-muted);
}
.preview {
position: relative;
flex-shrink: 0;
width: 120px;
max-width: 120px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
background: #f4f0e8;
}
.preview-img {
width: 100%;
max-height: 68px;
display: block;
object-fit: contain;
}
.preview-remove {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(31, 35, 32, 0.78);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.36);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.preview-remove:hover {
background: rgba(159, 47, 45, 0.92);
}
.actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.upload-btn,
.pick-btn {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-family: inherit;
font-weight: 700;
cursor: pointer;
}
.upload-btn {
border: 1px solid var(--primary);
background: var(--primary);
color: #fff;
}
.upload-btn.is-uploading,
.upload-btn.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pick-btn {
border: 1px solid var(--border);
background: #fff;
color: var(--text);
}
.pick-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.media-state {
text-align: center;
padding: 40px 0;
color: var(--text-muted);
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
max-height: 420px;
overflow-y: auto;
}
.media-card {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
}
.media-card:hover {
border-color: #d5cfc3;
box-shadow: 0 8px 22px rgba(56, 49, 37, 0.08);
}
.media-thumb {
height: 80px;
background: #f4f0e8;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.media-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.media-svg {
font-size: 14px;
font-weight: 700;
color: var(--text-muted);
}
.media-name {
padding: 6px 8px;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,531 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
const props = withDefaults(
defineProps<{
modelValue: string;
placeholder?: string;
uploadCategory?: string;
disabled?: boolean;
fill?: boolean;
}>(),
{ uploadCategory: 'contents', fill: false },
);
const emit = defineEmits<{ 'update:modelValue': [string] }>();
const { t } = useAdminLocale();
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
const EDITOR_IMAGE_MAX_HEIGHT = '200px';
const editorRef = ref<HTMLDivElement | null>(null);
const mediaPickerVisible = ref(false);
/** blob/object URL -> File保存时由父组件调用 uploadPendingImages 上传 */
const pendingFiles = new Map<string, File>();
const mediaFiles = ref<Array<{ id: string; filename: string; url: string; mimeType: string }>>([]);
const mediaLoading = ref(false);
const syncing = ref(false);
let savedRange: Range | null = null;
function isNodeInEditor(node: Node | null): boolean {
const el = editorRef.value;
if (!el || !node) return false;
return el.contains(node.nodeType === Node.TEXT_NODE ? node.parentNode : node);
}
function saveSelection() {
const sel = window.getSelection();
const el = editorRef.value;
if (!sel || sel.rangeCount === 0 || !el) return;
const range = sel.getRangeAt(0);
if (isNodeInEditor(range.commonAncestorContainer)) {
savedRange = range.cloneRange();
}
}
function restoreSelection(): boolean {
const el = editorRef.value;
if (!savedRange || !el) return false;
try {
if (!isNodeInEditor(savedRange.commonAncestorContainer)) return false;
const sel = window.getSelection();
if (!sel) return false;
sel.removeAllRanges();
sel.addRange(savedRange);
return true;
} catch {
savedRange = null;
return false;
}
}
function onDocumentSelectionChange() {
if (isNodeInEditor(window.getSelection()?.anchorNode ?? null)) {
saveSelection();
}
}
function normalizeHtml(html: string) {
const trimmed = html.trim();
if (!trimmed) return '';
return trimmed;
}
function syncFromModel() {
const el = editorRef.value;
if (!el) return;
syncing.value = true;
el.innerHTML = props.modelValue || '';
if (el.innerHTML) normalizeEditorImages(el);
syncing.value = false;
}
function emitChange() {
if (syncing.value || !editorRef.value) return;
const html = normalizeHtml(editorRef.value.innerHTML);
cleanupOrphanedBlobs(html);
emit('update:modelValue', html);
}
function cleanupOrphanedBlobs(html: string) {
const used = new Set<string>();
for (const match of html.matchAll(/blob:[^\s"'<>]+/g)) {
used.add(match[0]);
}
for (const blobUrl of pendingFiles.keys()) {
if (!used.has(blobUrl)) {
URL.revokeObjectURL(blobUrl);
pendingFiles.delete(blobUrl);
}
}
}
function revokeAllPendingBlobs() {
for (const blobUrl of pendingFiles.keys()) {
URL.revokeObjectURL(blobUrl);
}
pendingFiles.clear();
}
function exec(cmd: string, value?: string) {
editorRef.value?.focus();
document.execCommand(cmd, false, value);
emitChange();
}
function applyInlineImageStyles(img: HTMLImageElement) {
img.removeAttribute('width');
img.removeAttribute('height');
img.style.maxWidth = '100%';
img.style.width = 'auto';
img.style.height = 'auto';
img.style.maxHeight = EDITOR_IMAGE_MAX_HEIGHT;
img.style.objectFit = 'contain';
img.style.display = 'block';
img.style.margin = '10px 0';
img.style.borderRadius = '6px';
}
function createEditorImage(url: string, pending = false) {
const img = document.createElement('img');
img.src = url;
if (pending || url.startsWith('blob:')) {
img.setAttribute('data-local-blob', '1');
}
applyInlineImageStyles(img);
return img;
}
function normalizeEditorImages(root: HTMLElement) {
root.querySelectorAll('img').forEach((node) => applyInlineImageStyles(node as HTMLImageElement));
}
function insertImageUrl(url: string) {
const el = editorRef.value;
if (!el) return;
el.focus();
restoreSelection();
const img = createEditorImage(url, url.startsWith('blob:'));
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
if (isNodeInEditor(range.commonAncestorContainer)) {
range.deleteContents();
range.insertNode(img);
const after = document.createRange();
after.setStartAfter(img);
after.collapse(true);
sel.removeAllRanges();
sel.addRange(after);
savedRange = after.cloneRange();
normalizeEditorImages(el);
emitChange();
return;
}
}
el.appendChild(img);
normalizeEditorImages(el);
emitChange();
}
function insertPendingImage(file: File) {
if (file.size > MAX_UPLOAD_SIZE) {
ElMessage.error(t('content.upload.size_error'));
return;
}
saveSelection();
const blobUrl = URL.createObjectURL(file);
pendingFiles.set(blobUrl, file);
insertImageUrl(blobUrl);
}
async function uploadSingleFile(file: File): Promise<string> {
const fd = new FormData();
fd.append('file', file);
const { data } = await api.post(`/admin/uploads?category=${props.uploadCategory}`, fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const url = data.data?.url as string;
if (!url) {
throw new Error(t('content.upload.failed'));
}
return url;
}
/** 扫描 HTML 中的 blob: 图片,上传后替换为 /uploads/... URL */
async function uploadPendingImages(html: string): Promise<string> {
if (!html.includes('blob:')) return html;
let result = html;
const blobUrls = [...new Set([...html.matchAll(/blob:[^\s"'<>]+/g)].map((m) => m[0]))];
for (const blobUrl of blobUrls) {
const file = pendingFiles.get(blobUrl);
if (!file) continue;
const serverUrl = await uploadSingleFile(file);
result = result.replaceAll(blobUrl, serverUrl);
URL.revokeObjectURL(blobUrl);
pendingFiles.delete(blobUrl);
}
return result;
}
function getHtml(): string {
if (!editorRef.value) return normalizeHtml(props.modelValue);
return normalizeHtml(editorRef.value.innerHTML);
}
defineExpose({ uploadPendingImages, getHtml });
function onImageFileChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files?.[0]) {
insertPendingImage(input.files[0]);
input.value = '';
}
}
async function openMediaPicker() {
saveSelection();
mediaPickerVisible.value = true;
mediaLoading.value = true;
try {
const res = await api.get('/admin/files', {
params: { category: props.uploadCategory, pageSize: 200 },
});
mediaFiles.value = res.data.data.items ?? [];
} catch {
mediaFiles.value = [];
} finally {
mediaLoading.value = false;
}
}
function pickMediaFile(url: string) {
insertImageUrl(url);
mediaPickerVisible.value = false;
}
function onPaste(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
saveSelection();
const file = item.getAsFile();
if (file) insertPendingImage(file);
return;
}
}
void nextTick(() => {
const el = editorRef.value;
if (!el) return;
normalizeEditorImages(el);
emitChange();
});
}
watch(
() => props.modelValue,
(val, prev) => {
if (val === prev) return;
const el = editorRef.value;
if (!el) return;
if (normalizeHtml(el.innerHTML) !== normalizeHtml(val)) {
syncFromModel();
}
},
);
onMounted(async () => {
await nextTick();
syncFromModel();
document.addEventListener('selectionchange', onDocumentSelectionChange);
});
onBeforeUnmount(() => {
document.removeEventListener('selectionchange', onDocumentSelectionChange);
revokeAllPendingBlobs();
});
</script>
<template>
<div class="rich-editor" :class="{ 'is-disabled': disabled, 'rich-editor--fill': fill }">
<div class="toolbar">
<button type="button" title="Bold" :disabled="disabled" @mousedown.prevent @click="exec('bold')">
<strong>B</strong>
</button>
<button type="button" title="Italic" :disabled="disabled" @mousedown.prevent @click="exec('italic')">
<em>I</em>
</button>
<button type="button" :disabled="disabled" @mousedown.prevent @click="exec('insertUnorderedList')">
</button>
<button type="button" :disabled="disabled" @mousedown.prevent @click="exec('insertOrderedList')">
1.
</button>
<label class="toolbar-upload" @mousedown.prevent="saveSelection">
<input
type="file"
:accept="IMAGE_ACCEPT"
style="display: none"
:disabled="disabled"
@change="onImageFileChange"
/>
{{ t('content.editor.insert_image') }}
</label>
<button
type="button"
:disabled="disabled"
@mousedown.prevent="saveSelection"
@click="openMediaPicker"
>
{{ t('content.upload.pick_media') }}
</button>
</div>
<div
ref="editorRef"
class="editor-body"
:data-placeholder="placeholder || t('content.editor.placeholder')"
:contenteditable="disabled ? 'false' : 'true'"
@input="emitChange"
@blur="emitChange"
@paste="onPaste"
/>
</div>
<el-dialog
v-model="mediaPickerVisible"
:title="t('content.upload.pick_media_title')"
width="680px"
destroy-on-close
append-to-body
>
<div v-if="mediaLoading" class="media-state">{{ t('common.loading') }}</div>
<div v-else-if="mediaFiles.length === 0" class="media-state">{{ t('content.upload.no_media') }}</div>
<div v-else class="media-grid">
<div
v-for="file in mediaFiles"
:key="file.id"
class="media-card"
@click="pickMediaFile(file.url)"
>
<div class="media-thumb">
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
<div v-else class="media-svg">SVG</div>
</div>
<div class="media-name" :title="file.filename">{{ file.filename }}</div>
</div>
</div>
</el-dialog>
</template>
<style scoped>
.rich-editor {
width: 100%;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.rich-editor--fill {
display: flex;
flex-direction: column;
min-height: 420px;
height: 100%;
}
.rich-editor.is-disabled {
opacity: 0.7;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 5px 6px;
border-bottom: 1px solid var(--border);
background: #fbfaf7;
}
.toolbar button,
.toolbar-upload {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 26px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: #fff;
color: var(--text);
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.toolbar button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.toolbar-upload {
font-weight: 600;
}
.editor-body {
min-height: 140px;
max-height: 280px;
overflow-x: hidden;
overflow-y: auto;
padding: 8px 10px;
font-size: 13px;
line-height: 1.55;
color: var(--text);
outline: none;
word-break: break-word;
}
.rich-editor--fill .editor-body {
flex: 1;
min-height: 400px;
max-height: none;
}
.editor-body:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
pointer-events: none;
}
.editor-body :deep(img) {
box-sizing: border-box;
max-width: 100%;
width: auto;
height: auto;
max-height: 200px;
object-fit: contain;
display: block;
margin: 10px 0;
border-radius: 6px;
}
.rich-editor--fill .editor-body :deep(img) {
max-height: 200px;
}
.editor-body :deep(ul),
.editor-body :deep(ol) {
margin: 8px 0;
padding-left: 22px;
}
.media-state {
text-align: center;
padding: 40px 0;
color: var(--text-muted);
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
max-height: 420px;
overflow-y: auto;
}
.media-card {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
}
.media-card:hover {
border-color: #d5cfc3;
}
.media-thumb {
height: 80px;
background: #f4f0e8;
display: flex;
align-items: center;
justify-content: center;
}
.media-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.media-svg {
font-size: 14px;
font-weight: 700;
color: var(--text-muted);
}
.media-name {
padding: 6px 8px;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@@ -0,0 +1,40 @@
import { ref } from 'vue';
import api from '../api';
const pendingCount = ref(0);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollConsumers = 0;
export async function refreshDepositPendingCount() {
try {
const { data } = await api.get('/admin/deposit-orders/pending-count');
pendingCount.value = Number(data.data?.count ?? 0);
} catch {
pendingCount.value = 0;
}
}
export function useDepositPendingCount() {
function startDepositPendingPolling() {
pollConsumers += 1;
if (pollConsumers > 1) return;
void refreshDepositPendingCount();
pollTimer = setInterval(() => void refreshDepositPendingCount(), 30_000);
}
function stopDepositPendingPolling() {
pollConsumers = Math.max(0, pollConsumers - 1);
if (pollConsumers > 0) return;
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
return {
pendingCount,
refreshDepositPendingCount,
startDepositPendingPolling,
stopDepositPendingPolling,
};
}

View File

@@ -0,0 +1,27 @@
import { ref, onMounted, onActivated } from 'vue';
/**
* 列表页 mount / KeepAlive activated 生命周期:有缓存则后台静默刷新,无缓存则显示 loading。
*/
export function useStaleListLifecycle(
hasData: () => boolean,
loadFn: () => void | Promise<void>,
) {
const loading = ref(false);
async function runLoad(showSpinner: boolean) {
if (showSpinner) loading.value = true;
try {
await loadFn();
} finally {
loading.value = false;
}
}
onMounted(() => void runLoad(!hasData()));
onActivated(() => {
if (hasData()) void runLoad(false);
});
return { loading, runLoad };
}

View File

@@ -1,7 +1,6 @@
import { adminPagesEn, adminPagesZh } from './admin-pages';
import { adminPagesMs } from './admin-pages-ms';
/** 管理后台:中文 + 英文 + 马来语 */
/** 管理后台:中文 + 英文 + 马来语(核心短文案)
* 列表页/弹窗大段文案通过 bundles/ 动态 chunk 平载入,不再静态 import admin-pages
*/
export type AdminLocale = 'zh-CN' | 'en-US' | 'ms-MY';
export const ADMIN_LOCALES: {
@@ -35,6 +34,7 @@ const zh: Record<string, string> = {
'staff.col.last_login': '最近登录',
'staff.dialog.create': '创建后台账号',
'staff.dialog.edit': '编辑后台账号',
'staff.field.visible_menus': '菜单权限',
'login.captcha_ph': '验证码',
'login.captcha_refresh': '点击刷新',
@@ -69,6 +69,7 @@ const zh: Record<string, string> = {
'deposit.status_approved': '已通过',
'deposit.status_rejected': '已拒绝',
'deposit.search_player_ph': '搜索玩家...',
'deposit.pending_badge': '待审核 {n} 笔',
'deposit.order_no': '订单号',
'deposit.player': '玩家',
'deposit.amount': '金额',
@@ -114,7 +115,7 @@ const zh: Record<string, string> = {
'deposit.add_method': '+ 添加',
'deposit.display_name': '展示名称',
'deposit.details': '详情',
'deposit.sort': '排序',
'deposit.sort': '排序',
'deposit.active': '启用',
'deposit.show_player': '前台展示',
'deposit.edit_method': '编辑收款方式',
@@ -124,7 +125,7 @@ const zh: Record<string, string> = {
'deposit.account_number': '银行账号',
'deposit.usdt_address': 'USDT 地址',
'deposit.qr_code': '二维码',
'deposit.sort_order': '排序序号',
'deposit.sort_order': '排序',
'deposit.show_on_player': '展示给玩家',
'deposit.save': '保存',
'deposit.confirm_deactivate': '确认停用此收款方式?',
@@ -150,11 +151,12 @@ const zh: Record<string, string> = {
'role.tier2_agent': '二级代理',
'logout': '退出',
'lang': '语言',
'portal.admin': '平台后台',
'portal.agent': '代理后台',
'portal.admin': '平台后台管理',
'portal.agent': '代理后台管理',
'common.all': '全部',
'common.search': '查询',
'common.refresh': '刷新',
'common.reset': '重置',
'common.edit': '编辑',
'common.yes': '是',
@@ -164,9 +166,13 @@ const zh: Record<string, string> = {
'common.confirm': '确定',
'common.retry': '重试',
'common.status': '状态',
'user.status.ACTIVE': '正常',
'user.status.SUSPENDED': '已冻结',
'user.status.DISABLED': '已停用',
'common.type': '类型',
'common.keyword': '关键词',
'common.actions': '操作',
'common.seq': '序号',
'common.more': '更多',
'common.loading': '加载中…',
'common.no_data': '暂无数据',
@@ -218,6 +224,9 @@ const zh: Record<string, string> = {
'dash.match_pending_settle': '待结算',
'dash.match_settled': '已结算',
'dash.user_active': '正常玩家',
'dash.user_online': '当前在线',
'dash.players_online': '当前在线',
'dash.players_online_hint': '约 2 分钟内活跃',
'dash.user_suspended': '停用',
'dash.user_direct': '直属',
'dash.user_agents': '代理',
@@ -309,7 +318,7 @@ const zh: Record<string, string> = {
'match.status.CLOSED': '已封盘',
'match.status.SETTLED': '已结算',
'match.status.PENDING_SETTLEMENT': '待结算',
...adminPagesZh,
// 列表页/弹窗文案通过 bundles/zh-CN 动态平载,不在此处静态 spread
};
const en: Record<string, string> = {
@@ -334,6 +343,7 @@ const en: Record<string, string> = {
'staff.col.last_login': 'Last login',
'staff.dialog.create': 'Create staff account',
'staff.dialog.edit': 'Edit staff account',
'staff.field.visible_menus': 'Menu Permissions',
'login.captcha_ph': 'Captcha',
'login.captcha_refresh': 'Click to refresh',
@@ -370,6 +380,7 @@ const en: Record<string, string> = {
'deposit.status_approved': 'Approved',
'deposit.status_rejected': 'Rejected',
'deposit.search_player_ph': 'Search player...',
'deposit.pending_badge': '{n} pending',
'deposit.order_no': 'Order No',
'deposit.player': 'Player',
'deposit.amount': 'Amount',
@@ -449,11 +460,12 @@ const en: Record<string, string> = {
'role.tier2_agent': 'Tier-2 Agent',
'logout': 'Logout',
'lang': 'Language',
'portal.admin': 'Platform Admin',
'portal.agent': 'Agent Portal',
'portal.admin': 'Admin Console',
'portal.agent': 'Agent Console',
'common.all': 'All',
'common.search': 'Search',
'common.refresh': 'Refresh',
'common.reset': 'Reset',
'common.edit': 'Edit',
'common.yes': 'Yes',
@@ -463,9 +475,13 @@ const en: Record<string, string> = {
'common.confirm': 'OK',
'common.retry': 'Retry',
'common.status': 'Status',
'user.status.ACTIVE': 'Active',
'user.status.SUSPENDED': 'Suspended',
'user.status.DISABLED': 'Disabled',
'common.type': 'Type',
'common.keyword': 'Keyword',
'common.actions': 'Actions',
'common.seq': 'No.',
'common.more': 'More',
'common.loading': 'Loading…',
'common.no_data': 'No data',
@@ -517,6 +533,9 @@ const en: Record<string, string> = {
'dash.match_pending_settle': 'Pending settlement',
'dash.match_settled': 'Settled',
'dash.user_active': 'Active players',
'dash.user_online': 'Online now',
'dash.players_online': 'Online now',
'dash.players_online_hint': 'Active within ~2 min',
'dash.user_suspended': 'Suspended',
'dash.user_direct': 'Direct',
'dash.user_agents': 'Agents',
@@ -608,7 +627,7 @@ const en: Record<string, string> = {
'match.status.CLOSED': 'Closed',
'match.status.SETTLED': 'Settled',
'match.status.PENDING_SETTLEMENT': 'Pending settlement',
...adminPagesEn,
// 列表页/弹窗文案通过 bundles/en-US 动态平载,不在此处静态 spread
};
const ms: Record<string, string> = {
@@ -633,6 +652,7 @@ const ms: Record<string, string> = {
'staff.col.last_login': 'Log masuk terakhir',
'staff.dialog.create': 'Cipta akaun kakitangan',
'staff.dialog.edit': 'Edit akaun kakitangan',
'staff.field.visible_menus': 'Kebenaran Menu',
'login.captcha_ph': 'Captcha',
'login.captcha_refresh': 'Klik untuk muat semula',
@@ -669,6 +689,7 @@ const ms: Record<string, string> = {
'deposit.status_approved': 'Diluluskan',
'deposit.status_rejected': 'Ditolak',
'deposit.search_player_ph': 'Cari pemain...',
'deposit.pending_badge': '{n} menunggu',
'deposit.order_no': 'No. Pesanan',
'deposit.player': 'Pemain',
'deposit.amount': 'Jumlah',
@@ -748,11 +769,12 @@ const ms: Record<string, string> = {
'role.tier2_agent': 'Ejen Peringkat 2',
'logout': 'Log keluar',
'lang': 'Bahasa',
'portal.admin': 'Admin Platform',
'portal.agent': 'Portal Ejen',
'portal.admin': 'Pengurusan Admin',
'portal.agent': 'Pengurusan Ejen',
'common.all': 'Semua',
'common.search': 'Cari',
'common.refresh': 'Muat semula',
'common.reset': 'Set semula',
'common.edit': 'Edit',
'common.yes': 'Ya',
@@ -762,9 +784,13 @@ const ms: Record<string, string> = {
'common.confirm': 'OK',
'common.retry': 'Cuba lagi',
'common.status': 'Status',
'user.status.ACTIVE': 'Aktif',
'user.status.SUSPENDED': 'Digantung',
'user.status.DISABLED': 'Dinyahaktifkan',
'common.type': 'Jenis',
'common.keyword': 'Kata kunci',
'common.actions': 'Tindakan',
'common.seq': 'No.',
'common.more': 'Lagi',
'common.loading': 'Memuatkan…',
'common.no_data': 'Tiada data',
@@ -816,6 +842,9 @@ const ms: Record<string, string> = {
'dash.match_pending_settle': 'Menunggu penyelesaian',
'dash.match_settled': 'Diselesaikan',
'dash.user_active': 'Pemain aktif',
'dash.user_online': 'Dalam talian',
'dash.players_online': 'Dalam talian',
'dash.players_online_hint': 'Aktif dalam ~2 min',
'dash.user_suspended': 'Digantung',
'dash.user_direct': 'Terus',
'dash.user_agents': 'Ejen',
@@ -907,7 +936,7 @@ const ms: Record<string, string> = {
'match.status.CLOSED': 'Ditutup',
'match.status.SETTLED': 'Diselesaikan',
'match.status.PENDING_SETTLEMENT': 'Menunggu penyelesaian',
...adminPagesMs,
// 列表页/弹窗文案通过 bundles/ms-MY 动态平载,不在此处静态 spread
};
/** vue-i18n 文案表(扁平 key与原先 adminT 一致) */

View File

@@ -28,6 +28,10 @@ export const adminPagesMs: Record<string, string> = {
'user.filter.agent': 'Ejen',
'user.filter.agent_ph': 'Semua',
'user.col.username': 'Nama pengguna',
'user.col.online': 'Dalam talian',
'user.online_yes': 'Sedang dalam talian',
'user.presence_online': 'Dalam talian',
'user.presence_offline': 'Luar talian',
'user.col.agent': 'Ejen',
'user.col.invite_code': 'Kod jemputan',
'user.col.balance': 'Tersedia / Dibekukan',
@@ -765,11 +769,39 @@ export const adminPagesMs: Record<string, string> = {
'content.btn.enable': 'Aktifkan',
'content.btn.disable': 'Nyahaktif',
'content.dialog.create': 'Kandungan awam baharu',
'content.dialog.edit': 'Edit kandungan awam',
'content.dialog.create_banner': 'Promosi laman utama baharu',
'content.dialog.create_notice': 'Terbitkan notifikasi',
'content.dialog.edit': 'Edit kandungan',
'content.dialog.edit_banner': 'Edit promosi laman utama',
'content.dialog.edit_notice': 'Edit notifikasi',
'content.confirm_delete': 'Padam "{title}"?',
'content.type.BANNER': 'Banner laman utama',
'content.type.ANNOUNCEMENT': 'Pengumuman',
'content.hint.announcement': 'Dipaparkan di ticker atas pemain; isi tajuk atau kandungan',
'content.type.BANNER': 'Promosi laman utama',
'content.type.ANNOUNCEMENT': 'Notifikasi',
'content.type.INBOX_NOTIFY': 'Notifikasi peti mesej',
'content.inbox_notify.inbox_enabled': 'Peti mesej pemain',
'content.inbox_notify.inbox_enabled_hint': 'Jika dimatikan, pemain hanya dibawa ke sokongan (tiada tab peti mesej)',
'content.inbox_notify.deposit': 'Keputusan deposit',
'content.inbox_notify.deposit_hint': 'Hantar mesej apabila diluluskan atau ditolak',
'content.inbox_notify.manual_title': 'Tanda "Notifikasi peti mesej" semasa mencipta:',
'content.inbox_notify.banner_note': 'Promosi laman utama',
'content.inbox_notify.announcement_note': 'Notifikasi / ticker',
'content.hint.banner': 'Dipaparkan dalam karusel laman utama; ketik untuk halaman butiran. Muat naik imej muka depan dan kandungan kaya seperti pengumuman laman rasmi.',
'content.hint.announcement': 'Dipaparkan sebagai teks bergulir di bahagian atas aplikasi pemain. Isi tajuk dan teks bergulir setiap bahasa — teks biasa sahaja.',
'content.section.publish': 'Tetapan terbitan',
'content.section.content': 'Kandungan notifikasi',
'content.field.publish_kind': 'Jenis terbitan',
'content.publish_kind.notice': 'Notifikasi penuh (halaman butiran)',
'content.publish_kind.ticker': 'Teks ticker sahaja',
'content.publish_kind.hint': 'Notifikasi penuh muncul dalam senarai dan butiran; ticker hanya papar satu baris bergulir.',
'content.field.cover_image': 'Imej muka depan',
'content.field.ticker_title_ph': 'Tajuk ticker (pilihan)',
'content.field.ticker_body_ph': 'Teks dipaparkan dalam ticker',
'content.field.ticker_hint': 'Ticker hanya papar teks biasa — tiada imej atau format kaya.',
'content.editor.placeholder': 'Tulis kandungan notifikasi; boleh sisip imej dan senarai…',
'content.editor.insert_image': 'Sisip imej',
'content.upload.cover_size_hint': 'Lebar disyorkan 860px+. Imej muka depan dan dalam kandungan akan menyesuaikan lebar pada pemain.',
'content.upload.pick_media_title': 'Pilih imej',
'content.upload.no_media': 'Tiada imej dalam pustaka — muat naik dahulu',
'content.status.DRAFT': 'Draf',
'content.status.ACTIVE': 'Aktif',
'content.status.INACTIVE': 'Tidak aktif',
@@ -783,6 +815,9 @@ export const adminPagesMs: Record<string, string> = {
'content.field.link_target': 'Sasaran pautan',
'content.field.start_time': 'Masa mula',
'content.field.end_time': 'Masa tamat',
'content.field.notify_inbox': 'Notifikasi peti mesej',
'content.field.notify_inbox_hint': 'Hantar mesej peti kepada semua pemain tentang promosi ini selepas simpan',
'content.msg.notify_sent': 'Notifikasi dihantar kepada {count} pemain',
'content.field.title': 'Tajuk',
'content.field.title_ph': 'Pilihan',
'content.field.body': 'Kandungan',
@@ -795,8 +830,6 @@ export const adminPagesMs: Record<string, string> = {
'content.upload.size_error': 'Imej mestilah di bawah 5 MB',
'content.upload.remove': 'Buang imej',
'content.upload.pick_media': 'Pilih dari pustaka',
'content.upload.pick_media_title': 'Pilih Imej Banner',
'content.upload.no_media': 'Tiada imej banner dalam pustaka — muat naik dahulu',
'content.upload.url_placeholder': 'Atau tampal URL imej',
'content.upload.recommended_size': 'Saiz disyorkan: 860 x 360 px, atau imej nisbah 43:18. Karusel pemain memaparkan imej penuh dan mengisi ruang tambahan.',
'content.link.none': 'Tiada pautan',

View File

@@ -838,15 +838,33 @@ export const adminPagesZh: Record<string, string> = {
'content.btn.enable': '启用',
'content.btn.disable': '停用',
'content.dialog.create': '新建公共内容',
'content.dialog.edit': '编辑公共内容',
'content.dialog.create_banner': '新建首页推广',
'content.dialog.create_notice': '发布通知公告',
'content.dialog.edit': '编辑内容',
'content.dialog.edit_banner': '编辑首页推广',
'content.dialog.edit_notice': '编辑通知公告',
'content.confirm_delete': '确定删除「{title}」?',
'content.type.BANNER': '首页轮播',
'content.type.ANNOUNCEMENT': '公告滚动',
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
'content.type.BANNER': '首页推广',
'content.type.ANNOUNCEMENT': '通知公告',
'content.hint.banner': '用于首页轮播展示,点击后进入通知详情页;请填写封面图与正文,像官网发布活动通知一样编辑。',
'content.hint.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。',
'content.section.publish': '发布设置',
'content.section.content': '通知内容',
'content.field.publish_kind': '发布类型',
'content.publish_kind.notice': '完整通知(详情页)',
'content.publish_kind.ticker': '仅跑马灯文字',
'content.publish_kind.hint': '完整通知会出现在公告列表与详情页;跑马灯仅显示顶部滚动一行文字。',
'content.field.cover_image': '封面图',
'content.field.ticker_title_ph': '跑马灯标题(选填)',
'content.field.ticker_body_ph': '跑马灯显示的文字',
'content.field.ticker_hint': '跑马灯只显示纯文字,不支持图片与富文本格式。',
'content.editor.placeholder': '输入通知正文,可插入图片、列表等…',
'content.editor.insert_image': '插入图片',
'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。',
'content.status.DRAFT': '草稿',
'content.status.ACTIVE': '已启用',
'content.status.INACTIVE': '已停用',
'content.col.sort': '排序',
'content.col.sort': '排序',
'content.col.preview': '预览',
'content.col.title': '标题/摘要',
'content.col.player_visible': '玩家可见',
@@ -857,7 +875,7 @@ export const adminPagesZh: Record<string, string> = {
'content.field.start_time': '开始时间',
'content.field.end_time': '结束时间',
'content.field.title': '标题',
'content.field.title_ph': '选填,可与正文相同',
'content.field.title_ph': '通知标题,玩家端详情页展示',
'content.field.body': '正文',
'content.field.announce_text': '滚动文案',
'content.field.image_url': '图片地址',
@@ -868,8 +886,8 @@ export const adminPagesZh: Record<string, string> = {
'content.upload.size_error': '图片大小不能超过 5MB',
'content.upload.remove': '移除图片',
'content.upload.pick_media': '从媒体库选择',
'content.upload.pick_media_title': '选择 Banner 图片',
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
'content.upload.pick_media_title': '选择图片',
'content.upload.no_media': '媒体库中暂无图片,请先上传',
'content.upload.url_placeholder': '或手动粘贴图片 URL',
'content.upload.recommended_size': '建议尺寸860 x 360 px或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
'content.link.none': '无跳转',
@@ -1911,11 +1929,29 @@ export const adminPagesEn: Record<string, string> = {
'content.btn.enable': 'Enable',
'content.btn.disable': 'Disable',
'content.dialog.create': 'New public content',
'content.dialog.edit': 'Edit public content',
'content.dialog.create_banner': 'New home promotion',
'content.dialog.create_notice': 'Publish notification',
'content.dialog.edit': 'Edit content',
'content.dialog.edit_banner': 'Edit home promotion',
'content.dialog.edit_notice': 'Edit notification',
'content.confirm_delete': 'Delete "{title}"?',
'content.type.BANNER': 'Home banners',
'content.type.ANNOUNCEMENT': 'Announcements',
'content.hint.announcement': 'Shown in the player top marquee; fill title or body (body recommended)',
'content.type.BANNER': 'Home promotions',
'content.type.ANNOUNCEMENT': 'Notifications',
'content.hint.banner': 'Shown in the home carousel; tapping opens the detail page. Add a cover image and rich body like an official site announcement.',
'content.hint.announcement': 'Shows as a scrolling marquee at the top of the player app. Enter title and marquee text per language — plain text only.',
'content.section.publish': 'Publish settings',
'content.section.content': 'Notification content',
'content.field.publish_kind': 'Publish type',
'content.publish_kind.notice': 'Full notification (detail page)',
'content.publish_kind.ticker': 'Ticker text only',
'content.publish_kind.hint': 'Full notifications appear in the list and detail page; ticker-only shows one scrolling line at the top.',
'content.field.cover_image': 'Cover image',
'content.field.ticker_title_ph': 'Ticker title (optional)',
'content.field.ticker_body_ph': 'Text shown in the marquee',
'content.field.ticker_hint': 'Ticker shows plain text only — no images or rich formatting.',
'content.editor.placeholder': 'Write the notification body; you can insert images and lists…',
'content.editor.insert_image': 'Insert image',
'content.upload.cover_size_hint': 'Recommended width 860px+. Cover and inline images scale to fit on the player detail page.',
'content.status.DRAFT': 'Draft',
'content.status.ACTIVE': 'Active',
'content.status.INACTIVE': 'Inactive',
@@ -1930,7 +1966,7 @@ export const adminPagesEn: Record<string, string> = {
'content.field.start_time': 'Start time',
'content.field.end_time': 'End time',
'content.field.title': 'Title',
'content.field.title_ph': 'Optional; can match body',
'content.field.title_ph': 'Title shown on the player detail page',
'content.field.body': 'Body',
'content.field.announce_text': 'Marquee text',
'content.field.image_url': 'Image URL',
@@ -1941,8 +1977,8 @@ export const adminPagesEn: Record<string, string> = {
'content.upload.size_error': 'Image must be under 5 MB',
'content.upload.remove': 'Remove image',
'content.upload.pick_media': 'Pick from library',
'content.upload.pick_media_title': 'Select Banner Image',
'content.upload.no_media': 'No banner images in library — upload one first',
'content.upload.pick_media_title': 'Select image',
'content.upload.no_media': 'No images in library — upload one first',
'content.upload.url_placeholder': 'Or paste image URL',
'content.upload.recommended_size': 'Recommended size: 860 x 360 px, or any 43:18 image. The player carousel keeps the full image visible and fills extra space.',
'content.link.none': 'No link',

View File

@@ -1,3 +1,5 @@
// en-US 动态文案包:包含所有列表页/弹窗英文文案
// 通过 locale-loader ensureAdminLocaleLoaded('en-US') 按需加载
import adminPages from '../pages/en';
export default adminPages;
export default adminPages as Record<string, string>;

View File

@@ -1,3 +1,5 @@
// zh-CN 动态文案包:包含所有列表页/弹窗中文文案
// 通过 locale-loader ensureAdminLocaleLoaded('zh-CN') 按需加载
import adminPages from '../pages/zh';
export default adminPages;
export default adminPages as Record<string, string>;

View File

@@ -8,15 +8,17 @@ const loaders: Record<AdminLocale, () => Promise<{ default: Record<string, strin
};
const inflight = new Map<AdminLocale, Promise<void>>();
const loaded = new Set<AdminLocale>();
/** 按语言动态加载文案包(仅当前 locale 进入主路径,其余为独立 chunk。 */
export async function ensureAdminLocaleLoaded(locale: AdminLocale): Promise<void> {
if (Object.keys(adminMessages[locale]).length > 0) return;
if (loaded.has(locale)) return;
const pending = inflight.get(locale);
if (pending) return pending;
const task = loaders[locale]().then((mod) => {
adminMessages[locale] = mod.default;
Object.assign(adminMessages[locale], mod.default);
loaded.add(locale);
});
inflight.set(locale, task);
try {

View File

@@ -27,6 +27,10 @@ const adminPages: Record<string, string> = {
'user.filter.agent': 'Agent',
'user.filter.agent_ph': 'All',
'user.col.username': 'Username',
'user.col.online': 'Online',
'user.online_yes': 'Online now',
'user.presence_online': 'Online',
'user.presence_offline': 'Offline',
'user.col.agent': 'Agent',
'user.col.agent_cashback': 'Agent cashback',
'user.col.player_cashback': 'Player cashback',
@@ -353,10 +357,14 @@ const adminPages: Record<string, string> = {
'bet.col.result': 'Result',
'audit.module_ph': 'e.g. USERS, AGENTS',
'audit.col.time': 'Time',
'audit.col.operator': 'Operator',
'audit.col.action': 'Action',
'audit.col.module': 'Module',
'audit.col.target_id': 'Target ID',
'audit.col.time': 'Time',
'audit.col.ip': 'IP',
'audit.operator_system': 'System',
'audit.operator_player': 'Player',
'audit.action.CREATE_PLAYER': 'Create player',
'audit.action.UPDATE_PLAYER': 'Update player',
'audit.action.RESET_DATABASE': 'Reset database',
@@ -786,15 +794,43 @@ const adminPages: Record<string, string> = {
'msg.outright_odds_saved': 'Outright odds saved',
'msg.load_failed': 'Load failed',
'content.upload.pick_media_title': 'Select image',
'content.upload.no_media': 'No images in library — upload one first',
'content.btn.create': 'New content',
'content.btn.enable': 'Enable',
'content.btn.disable': 'Disable',
'content.dialog.create': 'New public content',
'content.dialog.edit': 'Edit public content',
'content.dialog.create_banner': 'New home promotion',
'content.dialog.create_notice': 'Publish notification',
'content.dialog.edit': 'Edit content',
'content.dialog.edit_banner': 'Edit home promotion',
'content.dialog.edit_notice': 'Edit notification',
'content.confirm_delete': 'Delete "{title}"?',
'content.type.BANNER': 'Home banners',
'content.type.ANNOUNCEMENT': 'Announcements',
'content.hint.announcement': 'Shown in the player top marquee; fill title or body (body recommended)',
'content.type.BANNER': 'Home promotions',
'content.type.ANNOUNCEMENT': 'Notifications',
'content.type.INBOX_NOTIFY': 'Inbox notify',
'content.inbox_notify.inbox_enabled': 'Player inbox',
'content.inbox_notify.inbox_enabled_hint': 'When off, the player hub opens support only (no mailbox tab)',
'content.inbox_notify.deposit': 'Deposit results',
'content.inbox_notify.deposit_hint': 'Auto-send inbox messages on approve or reject',
'content.inbox_notify.manual_title': 'Check "Inbox notify" when creating:',
'content.inbox_notify.banner_note': 'Homepage promo',
'content.inbox_notify.announcement_note': 'Announcements / ticker',
'content.hint.banner': 'Shown in the home carousel; tapping opens the detail page. Add a cover image and rich body like an official site announcement.',
'content.hint.announcement': 'Shows as a scrolling marquee at the top of the player app. Enter title and marquee text per language — plain text only.',
'content.section.publish': 'Publish settings',
'content.section.content': 'Notification content',
'content.field.publish_kind': 'Publish type',
'content.publish_kind.notice': 'Full notification (detail page)',
'content.publish_kind.ticker': 'Ticker text only',
'content.publish_kind.hint': 'Full notifications appear in the list and detail page; ticker-only shows one scrolling line at the top.',
'content.field.cover_image': 'Cover image',
'content.field.ticker_title_ph': 'Ticker title (optional)',
'content.field.ticker_body_ph': 'Text shown in the marquee',
'content.field.ticker_hint': 'Ticker shows plain text only — no images or rich formatting.',
'content.editor.placeholder': 'Write the notification body; you can insert images and lists…',
'content.editor.insert_image': 'Insert image',
'content.upload.cover_size_hint': 'Recommended width 860px+. Cover and inline images scale to fit on the player detail page.',
'content.status.DRAFT': 'Draft',
'content.status.ACTIVE': 'Active',
'content.status.INACTIVE': 'Inactive',
@@ -808,8 +844,11 @@ const adminPages: Record<string, string> = {
'content.field.link_target': 'Link target',
'content.field.start_time': 'Start time',
'content.field.end_time': 'End time',
'content.field.notify_inbox': 'Inbox notification',
'content.field.notify_inbox_hint': 'Send an inbox message to all players about this promotion after saving',
'content.msg.notify_sent': 'Inbox notification sent to {count} player(s)',
'content.field.title': 'Title',
'content.field.title_ph': 'Optional; can match body',
'content.field.title_ph': 'Title shown on the player detail page',
'content.field.body': 'Body',
'content.field.announce_text': 'Marquee text',
'content.field.image_url': 'Image URL',
@@ -820,8 +859,6 @@ const adminPages: Record<string, string> = {
'content.upload.size_error': 'Image must be under 5 MB',
'content.upload.remove': 'Remove image',
'content.upload.pick_media': 'Pick from library',
'content.upload.pick_media_title': 'Select Banner Image',
'content.upload.no_media': 'No banner images in library — upload one first',
'content.upload.url_placeholder': 'Or paste image URL',
'content.upload.recommended_size': 'Recommended size: 860 x 360 px, or any 43:18 image. The player carousel keeps the full image visible and fills extra space.',
'content.link.none': 'No link',

View File

@@ -28,6 +28,10 @@ const adminPages: Record<string, string> = {
'user.filter.agent': '所属代理',
'user.filter.agent_ph': '全部',
'user.col.username': '用户名',
'user.col.online': '在线',
'user.online_yes': '当前在线',
'user.presence_online': '在线',
'user.presence_offline': '离线',
'user.col.agent': '所属代理',
'user.col.agent_cashback': '代理返水率',
'user.col.player_cashback': '玩家返水率',
@@ -354,10 +358,14 @@ const adminPages: Record<string, string> = {
'bet.col.result': '赛果',
'audit.module_ph': '如 USERS、AGENTS',
'audit.col.time': '时间',
'audit.col.operator': '操作人',
'audit.col.action': '操作',
'audit.col.module': '模块',
'audit.col.target_id': '目标 ID',
'audit.col.time': '时间',
'audit.col.ip': 'IP',
'audit.operator_system': '系统',
'audit.operator_player': '玩家',
'audit.action.CREATE_PLAYER': '新建玩家',
'audit.action.UPDATE_PLAYER': '更新玩家',
'audit.action.RESET_DATABASE': '重置数据库',
@@ -790,15 +798,43 @@ const adminPages: Record<string, string> = {
'content.btn.enable': '启用',
'content.btn.disable': '停用',
'content.dialog.create': '新建公共内容',
'content.dialog.edit': '编辑公共内容',
'content.dialog.create_banner': '新建首页推广',
'content.dialog.create_notice': '发布通知公告',
'content.dialog.edit': '编辑内容',
'content.dialog.edit_banner': '编辑首页推广',
'content.dialog.edit_notice': '编辑通知公告',
'content.confirm_delete': '确定删除「{title}」?',
'content.type.BANNER': '首页轮播',
'content.type.ANNOUNCEMENT': '公告滚动',
'content.hint.announcement': '显示在玩家端顶部跑马灯;标题与正文填一项即可,建议正文为主',
'content.type.BANNER': '首页推广',
'content.type.ANNOUNCEMENT': '通知公告',
'content.type.INBOX_NOTIFY': '邮箱通知',
'content.inbox_notify.inbox_enabled': '站内邮箱',
'content.inbox_notify.inbox_enabled_hint': '关闭后玩家端入口直达客服,不展示邮箱标签页',
'content.inbox_notify.deposit': '充值结果',
'content.inbox_notify.deposit_hint': '审核通过或拒绝时自动发送站内信',
'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」',
'content.inbox_notify.banner_note': '首页推广',
'content.inbox_notify.announcement_note': '通知公告 / 跑马灯',
'content.hint.banner': '用于首页轮播展示,点击后进入通知详情页;请填写封面图与正文,像官网发布活动通知一样编辑。',
'content.hint.announcement': '在玩家端顶部显示滚动跑马灯文字;填写各语言标题与滚动文案即可,纯文本无富文本。',
'content.section.publish': '发布设置',
'content.section.content': '通知内容',
'content.field.publish_kind': '发布类型',
'content.publish_kind.notice': '完整通知(详情页)',
'content.publish_kind.ticker': '仅跑马灯文字',
'content.publish_kind.hint': '完整通知会出现在公告列表与详情页;跑马灯仅显示顶部滚动一行文字。',
'content.field.cover_image': '封面图',
'content.field.ticker_title_ph': '跑马灯标题(选填)',
'content.field.ticker_body_ph': '跑马灯显示的文字',
'content.field.ticker_hint': '跑马灯只显示纯文字,不支持图片与富文本格式。',
'content.editor.placeholder': '输入通知正文,可插入图片、列表等…',
'content.editor.insert_image': '插入图片',
'content.upload.cover_size_hint': '建议宽度 860px 以上;玩家端详情页会完整显示封面,正文内图片也会自适应宽度。',
'content.upload.pick_media_title': '选择图片',
'content.upload.no_media': '媒体库中暂无图片,请先上传',
'content.status.DRAFT': '草稿',
'content.status.ACTIVE': '已启用',
'content.status.INACTIVE': '已停用',
'content.col.sort': '排序',
'content.col.sort': '排序',
'content.col.preview': '预览',
'content.col.title': '标题/摘要',
'content.col.player_visible': '玩家可见',
@@ -808,8 +844,11 @@ const adminPages: Record<string, string> = {
'content.field.link_target': '链接目标',
'content.field.start_time': '开始时间',
'content.field.end_time': '结束时间',
'content.field.notify_inbox': '邮箱通知',
'content.field.notify_inbox_hint': '保存后向全部玩家发送站内信,通知查看此推广',
'content.msg.notify_sent': '已向 {count} 位玩家发送邮箱通知',
'content.field.title': '标题',
'content.field.title_ph': '选填,可与正文相同',
'content.field.title_ph': '通知标题,玩家端详情页展示',
'content.field.body': '正文',
'content.field.announce_text': '滚动文案',
'content.field.image_url': '图片地址',
@@ -820,8 +859,6 @@ const adminPages: Record<string, string> = {
'content.upload.size_error': '图片大小不能超过 5MB',
'content.upload.remove': '移除图片',
'content.upload.pick_media': '从媒体库选择',
'content.upload.pick_media_title': '选择 Banner 图片',
'content.upload.no_media': '媒体库中暂无 Banner 图片,请先上传',
'content.upload.url_placeholder': '或手动粘贴图片 URL',
'content.upload.recommended_size': '建议尺寸860 x 360 px或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
'content.link.none': '无跳转',

View File

@@ -9,6 +9,8 @@ import { AdminPerm } from '../constants/permissions';
import AdminLocaleSwitcher from '../components/AdminLocaleSwitcher.vue';
import AdminNavIcon from '../components/AdminNavIcon.vue';
import { resolveAdminBreadcrumb } from '../utils/admin-breadcrumb';
import { useDepositPendingCount } from '../composables/useDepositPendingCount';
import { prefetchRouteChunks } from '../utils/route-prefetch';
const route = useRoute();
const router = useRouter();
@@ -16,11 +18,38 @@ const auth = useAuthStore();
const { t } = useAdminLocale();
const { allowed: smokeTestsAllowed, ensureLoaded: ensureSmokeTestsAllowed } = useSmokeTestsAllowed();
const { hasPermission, role: adminRole } = usePermissions();
const { pendingCount: depositPendingCount, startDepositPendingPolling, stopDepositPendingPolling } =
useDepositPendingCount();
/**
* 列表页 KeepAlive 白名单(与各页面 defineOptions({ name }) 保持一致)。
* 不含 Settlement/MatchEventEditor/MatchMarketsPage 等带参数的详情页,避免缓存污染。
*/
const keepAliveIncludes = [
'AdminBets',
'AdminCashback',
'AdminMatches',
'AdminMatchesOutrights',
'AdminDepositManage',
'AdminStaffManage',
'AdminFinanceLogs',
'AdminAgentManager',
'AdminContents',
'AdminMediaLibrary',
'AdminAudit',
'AgentPlayers',
'AgentBets',
];
const canSeeDepositPending = computed(
() => auth.isAdmin.value && hasPermission(AdminPerm.depositReview, AdminPerm.depositManage),
);
const sidebarOpen = ref(false);
const isMobileNav = ref(false);
type AdminMenuItem = {
key: string;
path: string;
label: string;
icon: string;
@@ -34,23 +63,30 @@ function menuVisible(item: AdminMenuItem): boolean {
const code = adminRole.value;
if (item.path === '/smoke-tests' && smokeTestsAllowed.value === false) return false;
if (code && code !== 'SUPER_ADMIN' && item.excludeRoles?.includes(code)) return false;
return hasPermission(...item.permissions);
if (!hasPermission(...item.permissions)) return false;
const visible = auth.user.value?.visibleMenus;
if (visible) {
const list = visible.split(',');
return list.includes(item.key);
}
return true;
}
const adminMenus = computed(() => {
const items: AdminMenuItem[] = [
{ path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true, permissions: [AdminPerm.reports], excludeRoles: ['SUPPORT'] },
{ path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true, permissions: [AdminPerm.matches] },
{ path: '/users', label: t('nav.agents_players'), icon: 'users', permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
{ path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance', permissions: [AdminPerm.reports], excludeRoles: ['MATCH_ADMIN'] },
{ path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true, permissions: [AdminPerm.depositManage, AdminPerm.depositReview] },
{ path: '/cashback', label: t('nav.cashback'), icon: 'cashback', permissions: [AdminPerm.cashback], excludeRoles: ['MATCH_ADMIN', 'SUPPORT'] },
{ path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
{ path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
{ path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
{ path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
{ path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
{ path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
{ key: 'dashboard', path: '/', label: t('nav.dashboard'), icon: 'dashboard', matchPrefix: true, permissions: [AdminPerm.reports], excludeRoles: ['SUPPORT'] },
{ key: 'matches', path: '/matches', label: t('nav.matches'), icon: 'matches', matchPrefix: true, permissions: [AdminPerm.matches] },
{ key: 'users', path: '/users', label: t('nav.agents_players'), icon: 'users', permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
{ key: 'finance-logs', path: '/finance-logs', label: t('nav.finance_logs'), icon: 'finance', permissions: [AdminPerm.reports], excludeRoles: ['MATCH_ADMIN'] },
{ key: 'deposit', path: '/deposit', label: t('nav.deposit_manage'), icon: 'deposit', matchPrefix: true, permissions: [AdminPerm.depositManage, AdminPerm.depositReview] },
{ key: 'cashback', path: '/cashback', label: t('nav.cashback'), icon: 'cashback', permissions: [AdminPerm.cashback], excludeRoles: ['MATCH_ADMIN', 'SUPPORT'] },
{ key: 'bets', path: '/bets', label: t('nav.bets'), icon: 'bets', permissions: [AdminPerm.bets] },
{ key: 'contents', path: '/contents', label: t('nav.contents'), icon: 'contents', permissions: [AdminPerm.content] },
{ key: 'media', path: '/media', label: t('nav.media'), icon: 'media', permissions: [AdminPerm.content, AdminPerm.matches] },
{ key: 'audit', path: '/audit', label: t('nav.audit'), icon: 'audit', permissions: [AdminPerm.audit], excludeRoles: ['MATCH_ADMIN'] },
{ key: 'staff', path: '/staff', label: t('nav.staff'), icon: 'users', permissions: [AdminPerm.settings] },
{ key: 'smoke-tests', path: '/smoke-tests', label: t('nav.smoke_tests'), icon: 'smoke-tests', permissions: [AdminPerm.settings] },
];
return items.filter(menuVisible);
});
@@ -139,6 +175,10 @@ function onNavClick() {
}
}
function prefetchNavRoute(path: string) {
prefetchRouteChunks(router, path);
}
function logout() {
auth.logout();
router.push('/login');
@@ -149,11 +189,13 @@ onMounted(() => {
window.addEventListener('resize', syncMobileNav);
if (auth.isAdmin.value) {
void ensureSmokeTestsAllowed();
if (canSeeDepositPending.value) startDepositPendingPolling();
}
});
onUnmounted(() => {
window.removeEventListener('resize', syncMobileNav);
stopDepositPendingPolling();
});
watch(() => route.path, () => {
@@ -177,6 +219,7 @@ watch(() => route.path, () => {
<aside class="sidebar" :class="{ open: sidebarOpen }">
<div class="brand">
<img src="/logo.png" alt="TheBet365" class="brand-logo" />
<span class="brand-title">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</span>
</div>
<nav class="nav">
@@ -195,9 +238,20 @@ watch(() => route.path, () => {
: isMatchesSectionPath(route.path))),
}"
@click="onNavClick"
@mouseenter="prefetchNavRoute(m.path)"
@focus="prefetchNavRoute(m.path)"
>
<AdminNavIcon :name="m.icon" />
<span class="nav-label">{{ m.label }}</span>
<span class="nav-label">
{{ m.label }}
<span
v-if="'path' in m && m.path === '/deposit' && depositPendingCount > 0"
class="nav-pending-badge"
:title="t('deposit.pending_badge', { n: depositPendingCount })"
>
{{ depositPendingCount > 99 ? '99+' : depositPendingCount }}
</span>
</span>
</RouterLink>
</nav>
@@ -245,12 +299,15 @@ watch(() => route.path, () => {
</div>
</div>
<AdminLocaleSwitcher />
<div class="portal-tag">{{ isAdminPortal ? t('portal.admin') : t('portal.agent') }}</div>
<button class="btn-logout" @click="logout">{{ t('logout') }}</button>
</div>
</header>
<main class="page-main">
<RouterView />
<RouterView v-slot="{ Component }">
<KeepAlive :max="10" :include="keepAliveIncludes">
<component :is="Component" />
</KeepAlive>
</RouterView>
</main>
</div>
</div>
@@ -285,17 +342,26 @@ watch(() => route.path, () => {
border-bottom: 1px solid #1a1a1a;
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 8px;
flex-shrink: 0;
box-sizing: border-box;
}
.brand-logo {
max-width: 118px;
max-height: 34px;
max-width: 52px;
max-height: 32px;
width: auto;
height: auto;
object-fit: contain;
display: block;
flex-shrink: 0;
}
.brand-title {
font-size: 12px;
font-weight: 700;
line-height: 1.25;
color: #f0f0f0;
letter-spacing: 0.02em;
}
.nav {
@@ -324,8 +390,26 @@ watch(() => route.path, () => {
transition: opacity 0.15s;
}
.nav-label {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
line-height: 1.25;
flex: 1;
}
.nav-pending-badge {
flex-shrink: 0;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #f56c6c;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px rgba(245, 108, 108, 0.25);
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.04);
@@ -584,14 +668,23 @@ watch(() => route.path, () => {
.brand {
height: 64px;
min-height: 64px;
padding: 0 16px;
padding: 0 12px;
border-bottom: 1px solid var(--border-soft);
justify-content: flex-start;
gap: 10px;
}
.brand-logo {
max-width: 132px;
max-height: 38px;
max-width: 56px;
max-height: 34px;
}
.brand-title {
font-size: 13px;
font-weight: 700;
line-height: 1.25;
color: #2a2824;
letter-spacing: 0.02em;
}
.nav {

View File

@@ -1,5 +1,4 @@
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import App from './App.vue';
import router from './router';
@@ -10,7 +9,7 @@ applyElementPlusDialogDefaults();
async function bootstrap() {
const i18n = await createAdminI18n();
createApp(App).use(i18n).use(router).use(ElementPlus).mount('#app');
createApp(App).use(i18n).use(router).mount('#app');
}
void bootstrap();

View File

@@ -1,7 +1,8 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useSmokeTestsAllowed } from '../composables/useSmokeTestsAllowed';
import { ensureStaffSession } from '../utils/session-hydrate';
import { hydrateStaffSession } from '../utils/session-hydrate';
import { reconcileStaffSessionFromToken } from '../stores/auth';
import { AdminPerm } from '../constants/permissions';
import { adminCanAccess, firstAdminFallback } from '../utils/admin-access';
@@ -191,7 +192,9 @@ router.beforeEach(async (to) => {
const hasToken = !!auth.token.value;
if (hasToken) {
await ensureStaffSession();
reconcileStaffSessionFromToken();
// 后台刷新 session不阻塞路由切换401 由 api 拦截器处理
void hydrateStaffSession();
}
const hasUser = !!auth.user.value?.userType;
@@ -240,12 +243,35 @@ router.beforeEach(async (to) => {
}
if (to.meta.smokeTestsOnly) {
const { ensureLoaded, allowed } = useSmokeTestsAllowed();
await ensureLoaded();
if (!allowed.value) return '/';
const { allowed } = useSmokeTestsAllowed();
if (allowed.value === false) return '/';
}
return true;
});
/** 发版后旧 index.html 可能引用已不存在的 hash chunk自动刷新一次拉最新入口。 */
function isLazyChunkLoadError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err ?? '');
return (
msg.includes('Failed to fetch dynamically imported module') ||
msg.includes('Importing a module script failed') ||
msg.includes('error loading dynamically imported module')
);
}
const CHUNK_RELOAD_FLAG = 'admin:chunk-reload';
router.onError((error, to) => {
if (!isLazyChunkLoadError(error)) throw error;
const target = to.fullPath || '/';
if (!sessionStorage.getItem(CHUNK_RELOAD_FLAG)) {
sessionStorage.setItem(CHUNK_RELOAD_FLAG, target);
window.location.assign(target);
return;
}
sessionStorage.removeItem(CHUNK_RELOAD_FLAG);
throw error;
});
export default router;

View File

@@ -13,6 +13,7 @@ export interface StaffUser {
maxAgentLevel?: number | null;
canManageSubAgents?: boolean;
inviteCode?: string | null;
visibleMenus?: string | null;
}
const TOKEN_KEY = 'manage_token';
@@ -98,6 +99,7 @@ export function reconcileStaffSessionFromToken(): boolean {
role: claims.role ?? user.value?.role,
permissions: user.value?.permissions,
inviteCode: user.value?.inviteCode,
visibleMenus: user.value?.visibleMenus,
};
user.value = next;
localStorage.setItem(USER_KEY, JSON.stringify(next));

View File

@@ -0,0 +1,12 @@
/** 去除 HTML 标签,用于列表摘要与跑马灯纯文本 */
export function stripHtml(html: string): string {
if (!html) return '';
if (!/[<>]/.test(html)) return html.trim();
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
}
/** 判断富文本是否实质为空 */
export function isHtmlEmpty(html: string): boolean {
return !stripHtml(html);
}

View File

@@ -0,0 +1,25 @@
import type { Router } from 'vue-router';
const prefetchedPaths = new Set<string>();
/** 预取目标路径上所有 lazy route 的 JS chunk侧边栏 hover/focus 时调用)。 */
export function prefetchRouteChunks(router: Router, path: string) {
const key = path.split('?')[0] || '/';
if (prefetchedPaths.has(key)) return;
let matched = false;
try {
const resolved = router.resolve(path);
for (const record of resolved.matched) {
const loader = record.components?.default;
if (typeof loader === 'function') {
matched = true;
void (loader as () => Promise<unknown>)();
}
}
} catch {
return;
}
if (matched) prefetchedPaths.add(key);
}

View File

@@ -20,6 +20,17 @@ export function resetStaffSessionHydration() {
lastHydrateAt = 0;
}
/** 同步判断 session 是否已经新鲜,无需网络请求。主要给 router beforeEach 使用。 */
export function isSessionFresh(): boolean {
const auth = useAuthStore();
if (!auth.token.value) return false;
return (
lastHydrateAt > 0 &&
Date.now() - lastHydrateAt < HYDRATE_TTL_MS &&
hasCompleteStaffUser(auth.user.value)
);
}
function hasCompleteStaffUser(u: StaffUser | null | undefined): u is StaffUser {
return !!(u?.id && u.username && u.userType);
}

View File

@@ -26,7 +26,7 @@ interface CreditTxRow {
const items = ref<CreditTxRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const pageSize = ref(10);
const keyword = ref('');
const agentId = ref('');
const transactionType = ref('');

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch, reactive, h } from 'vue';
import { ref, onMounted, onActivated, computed, watch, reactive, h } from 'vue';
defineOptions({ name: 'AdminAgentManager' });
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
@@ -77,6 +79,7 @@ import { formatRatePercent, percentToDecimalRate, decimalRateToPercent } from '.
import InviteCodePanel from '../components/InviteCodePanel.vue';
import InviteManageDialog from '../components/InviteManageDialog.vue';
import AdminTableWrap from '../components/AdminTableWrap.vue';
import AdminPlayerStatusCell from '../components/AdminPlayerStatusCell.vue';
import AdminAgentRowActions from '../components/AdminAgentRowActions.vue';
import AdminPlayerRowActions from '../components/AdminPlayerRowActions.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
@@ -94,7 +97,7 @@ const inviteDialogOpen = ref(false);
const tier1Agents = ref<AgentRow[]>([]);
const tier1Total = ref(0);
const tier1Page = ref(1);
const tier1PageSize = ref(20);
const tier1PageSize = ref(10);
const tier1Keyword = ref('');
const tier1FilterStatus = ref('');
@@ -117,7 +120,7 @@ function ensureSubAgentState(level: number): SubAgentLevelState {
agents: [],
total: 0,
page: 1,
pageSize: 20,
pageSize: 10,
keyword: '',
filterStatus: '',
};
@@ -166,7 +169,7 @@ const activeViewTab = ref('players');
const allPlayers = ref<PlayerRow[]>([]);
const playerTotal = ref(0);
const playerPage = ref(1);
const playerPageSize = ref(20);
const playerPageSize = ref(10);
const playerKeyword = ref('');
const playerFilterStatus = ref('');
const playerFilterAgent = ref('');
@@ -411,15 +414,50 @@ function resolveCreateParentLabel(agentId: string) {
}
/* ─── Init ─── */
let pageInitPromise: Promise<void> | null = null;
function ensurePageInit(): Promise<void> {
if (settingsLoaded.value) return Promise.resolve();
if (!pageInitPromise) {
pageInitPromise = loadUsersPageInit().finally(() => {
pageInitPromise = null;
});
}
return pageInitPromise;
}
function loadActiveViewTabData() {
const tab = activeViewTab.value;
if (tab === 'players') {
if (canViewUsers.value) void loadAllPlayers();
return;
}
if (!canViewAgents.value) return;
if (tab === 'tier1Agents') {
void loadTier1Agents();
return;
}
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
void ensurePageInit().then(() => loadSubAgentsAtLevel(Number(m[1])));
}
}
onMounted(() => {
if (canManageSettings.value) {
void loadUsersPageInit();
}
if (canViewUsers.value) {
loadAllPlayers();
}
if (canViewAgents.value) {
loadTier1Agents();
loadActiveViewTabData();
});
// KeepAlive 激活时静默刷新当前 tab 列表(不重复 page-init
onActivated(() => {
const tab = activeViewTab.value;
if (tab === 'players' && canViewUsers.value && allPlayers.value.length > 0) void loadAllPlayers();
else if (tab === 'tier1Agents' && canViewAgents.value && tier1Agents.value.length > 0) void loadTier1Agents();
else {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
const lvl = Number(m[1]);
const st = subAgentLevelState[lvl];
if (st?.agents.length) loadSubAgentsAtLevel(lvl);
}
}
});
@@ -448,9 +486,6 @@ async function loadUsersPageInit() {
}
if (payload.agentLevelCounts) {
agentLevelCounts.value = payload.agentLevelCounts;
for (const lvl of visibleSubAgentTabLevels.value) {
loadSubAgentsAtLevel(lvl);
}
}
settingsLoaded.value = true;
} catch {
@@ -662,14 +697,22 @@ function onSubAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent)
watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
if (m) {
void ensurePageInit().then(() => loadSubAgentsAtLevel(Number(m[1])));
return;
}
if (tab === 'players' && canViewUsers.value && !allPlayers.value.length) void loadAllPlayers();
if (tab === 'tier1Agents' && canViewAgents.value && !tier1Agents.value.length) void loadTier1Agents();
});
watch(visibleSubAgentTabLevels, (levels, prev) => {
for (const lvl of levels) {
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) {
loadSubAgentsAtLevel(lvl);
}
watch(visibleSubAgentTabLevels, (levels) => {
const tab = activeViewTab.value;
const m = /^agentLevel-(\d+)$/.exec(tab);
if (!m) return;
const activeLevel = Number(m[1]);
if (levels.includes(activeLevel)) {
const st = subAgentLevelState[activeLevel];
if (!st?.agents.length) loadSubAgentsAtLevel(activeLevel);
}
});
@@ -1557,11 +1600,11 @@ function creditTypeLabel(type: string) {
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column type="index" :index="(i: number) => (playerPage - 1) * playerPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" width="88">
<el-table-column :label="t('common.status')" min-width="128">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
</template>
</el-table-column>
<el-table-column :label="t('user.col.agent')" min-width="200">
@@ -1671,19 +1714,19 @@ function creditTypeLabel(type: string) {
</div>
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" min-width="120">
<template #default="{ row: player }">
<AdminPlayerStatusCell :status="player.status" :is-online="player.isOnline" />
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
<template #default="{ row: player }">
<el-tooltip :content="`${formatAmountFull(player.availableBalance)} / ${formatAmountFull(player.frozenBalance)}`" placement="top">
@@ -1717,8 +1760,7 @@ function creditTypeLabel(type: string) {
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" min-width="64" />
<el-table-column type="index" :index="(i: number) => (tier1Page - 1) * tier1PageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
<el-table-column :label="t('common.status')" min-width="72">
<template #default="{ row }">
@@ -1837,19 +1879,19 @@ function creditTypeLabel(type: string) {
</div>
<el-table :data="getPlayers(row.userId)" stripe class="inner-table">
<template #empty><AdminTableEmpty /></template>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column :label="t('common.status')" min-width="120">
<template #default="{ row: player }">
<AdminPlayerStatusCell :status="player.status" :is-online="player.isOnline" />
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row: player }">
<code v-if="player.inviteCode" class="invite-code-cell">{{ player.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="80">
<template #default="{ row: player }">
<el-tag :type="statusTagType(player.status)" size="small">{{ statusLabel(player.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
<template #default="{ row: player }">
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
@@ -1875,7 +1917,7 @@ function creditTypeLabel(type: string) {
</div>
</template>
</el-table-column>
<el-table-column prop="userId" label="ID" min-width="64" />
<el-table-column type="index" :index="(i: number) => (ensureSubAgentState(agentLevel).page - 1) * ensureSubAgentState(agentLevel).pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" show-overflow-tooltip />
<el-table-column :label="t('agent.col.parent_chain')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ parentChainLabel(row) }}</template>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
defineOptions({ name: 'AdminAudit' });
import AuditLogTable from '../components/AuditLogTable.vue';
</script>

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref } from 'vue';
defineOptions({ name: 'AdminBets' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import api from '../api';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import {
@@ -32,14 +35,6 @@ const detailVisible = ref(false);
const detail = ref<BetDetail | null>(null);
const detailLoading = ref(false);
onMounted(load);
function betContentCounts(row: BetListRow) {
const singles = row.betType === 'SINGLE' ? 1 : 0;
const parlays = row.betType === 'PARLAY' ? 1 : 0;
return t('bet.content.bet_counts', { singles, parlays });
}
async function load() {
const { data } = await api.get('/admin/bets', {
params: {
@@ -56,15 +51,17 @@ async function load() {
total.value = data.data.total;
}
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => bets.value.length > 0, load);
function onPageChange(p: number) {
page.value = p;
load();
void runLoad(true);
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
void runLoad(true);
}
function resetFilters() {
@@ -74,7 +71,13 @@ function resetFilters() {
placedFrom.value = '';
placedTo.value = '';
page.value = 1;
load();
void runLoad(true);
}
function betContentCounts(row: BetListRow) {
const singles = row.betType === 'SINGLE' ? 1 : 0;
const parlays = row.betType === 'PARLAY' ? 1 : 0;
return t('bet.content.bet_counts', { singles, parlays });
}
function parentLabel(row: BetListRow) {
@@ -162,18 +165,19 @@ async function openDetail(row: BetListRow) {
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="load">{{ t('common.search') }}</el-button>
<el-button type="primary" @click="runLoad(true)">{{ t('common.search') }}</el-button>
<el-button @click="resetFilters">{{ t('common.reset') }}</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="data-card" shadow="never">
<el-card v-loading="listLoading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="bets" stripe class="bets-table">
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="id" :label="t('bet.col.serial')" width="64" align="center" />
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" min-width="200" show-overflow-tooltip>
<template #default="{ row }">

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, onActivated, ref } from 'vue';
defineOptions({ name: 'AdminCashback' });
import type { TableColumnCtx } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
@@ -127,8 +129,8 @@ function tableSummary(param: {
});
}
async function loadHistory() {
historyLoading.value = true;
async function loadHistory(opts?: { silent?: boolean }) {
if (!opts?.silent) historyLoading.value = true;
try {
const { data } = await api.get('/admin/cashbacks', {
params: {
@@ -142,7 +144,7 @@ async function loadHistory() {
} catch (err) {
ElMessage.error(resolveApiError(err, t, 'msg.load_failed'));
} finally {
historyLoading.value = false;
if (!opts?.silent) historyLoading.value = false;
}
}
@@ -248,7 +250,10 @@ function onHistoryStatusChange() {
loadHistory();
}
onMounted(loadHistory);
onMounted(() => void loadHistory());
onActivated(() => {
if (history.value.length > 0) void loadHistory({ silent: true });
});
</script>
<template>
@@ -453,6 +458,7 @@ onMounted(loadHistory);
<template #empty>
<AdminTableEmpty :text="t('cashback.history_empty')" />
</template>
<el-table-column type="index" :index="(i: number) => (historyPage - 1) * historyPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="batchNo" :label="t('cashback.batch_no')" min-width="140" show-overflow-tooltip />
<el-table-column :label="t('cashback.col.period')" min-width="190">
<template #default="{ row }">{{ formatPeriodRange(row) }}</template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
defineOptions({ name: 'AdminContents' });
import { ref, computed, watch, onActivated } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import type { TableInstance } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
@@ -7,6 +9,9 @@ import { usePermissions } from '../composables/usePermissions';
import { AdminPerm } from '../constants/permissions';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import ContentImageField from '../components/ContentImageField.vue';
import ContentRichEditor from '../components/ContentRichEditor.vue';
import { stripHtml } from '../utils/html';
import {
normalizeStartTimeForApi,
normalizeStartTimeForPicker,
@@ -16,91 +21,8 @@ const { t, localeTag } = useAdminLocale();
const { hasPermission } = usePermissions();
const canManageContent = computed(() => hasPermission(AdminPerm.content));
/* ── Image upload helpers ── */
const IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp,image/gif,image/svg+xml';
const MAX_UPLOAD_SIZE = 5 * 1024 * 1024;
interface MediaFile {
id: string;
filename: string;
category: string;
mimeType: string;
size: number;
url: string;
inUse: boolean;
createdAt: string;
}
/** Per-locale uploading state */
const uploadingLocale = ref<string | null>(null);
/** Media picker state */
const mediaPickerVisible = ref(false);
const mediaPickerLocale = ref('');
const mediaFiles = ref<MediaFile[]>([]);
const mediaLoading = ref(false);
async function uploadBannerImage(locale: string, file: File) {
if (file.size > MAX_UPLOAD_SIZE) {
ElMessage.error(t('content.upload.size_error'));
return;
}
uploadingLocale.value = locale;
try {
const fd = new FormData();
fd.append('file', file);
const { data } = await api.post('/admin/uploads?category=banners', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const url = data.data?.url as string;
if (url) {
const tr = form.value.translations.find((item) => item.locale === locale);
if (tr) tr.imageUrl = url;
ElMessage.success(t('content.upload.success'));
}
} catch (err: any) {
const msg = err?.response?.data?.message || t('content.upload.failed');
ElMessage.error(String(msg));
} finally {
uploadingLocale.value = null;
}
}
function onBannerFileChange(e: Event, locale: string) {
const input = e.target as HTMLInputElement;
if (input.files?.[0]) {
void uploadBannerImage(locale, input.files[0]);
input.value = '';
}
}
function removeBannerImage(locale: string) {
const tr = form.value.translations.find((item) => item.locale === locale);
if (tr) tr.imageUrl = '';
}
async function openMediaPicker(locale: string) {
mediaPickerLocale.value = locale;
mediaPickerVisible.value = true;
mediaLoading.value = true;
try {
const res = await api.get('/admin/files', { params: { category: 'banners', pageSize: 200 } });
mediaFiles.value = res.data.data.items ?? [];
} catch {
mediaFiles.value = [];
} finally {
mediaLoading.value = false;
}
}
function pickMediaFile(file: MediaFile) {
const tr = form.value.translations.find((item) => item.locale === mediaPickerLocale.value);
if (tr) tr.imageUrl = file.url;
mediaPickerVisible.value = false;
}
type StoredContentType = 'BANNER' | 'NOTICE' | 'TICKER';
type AdminTab = 'BANNER' | 'ANNOUNCEMENT';
type AdminTab = 'BANNER' | 'ANNOUNCEMENT' | 'INBOX_NOTIFY';
type ContentStatus = 'DRAFT' | 'ACTIVE' | 'INACTIVE';
interface TranslationForm {
@@ -126,7 +48,7 @@ interface ContentItem {
translations: TranslationForm[];
}
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT'];
const ADMIN_TABS: AdminTab[] = ['BANNER', 'ANNOUNCEMENT', 'INBOX_NOTIFY'];
const LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
const activeType = ref<AdminTab>('BANNER');
@@ -142,9 +64,24 @@ const selectedRows = ref<ContentItem[]>([]);
const hasSelection = computed(() => selectedRows.value.length > 0);
const dialogVisible = ref(false);
const bannerDialogVisible = ref(false);
const announcementDialogVisible = ref(false);
const bannerEditorRef = ref<InstanceType<typeof ContentRichEditor> | null>(null);
const editingId = ref<string | null>(null);
const editingContentType = ref<StoredContentType>('NOTICE');
const editingContentType = ref<StoredContentType>('TICKER');
const activeLocale = ref<string>('zh-CN');
const notifyInbox = ref(false);
interface InboxNotifySettings {
inboxEnabled: boolean;
deposit: boolean;
}
const inboxNotifySettings = ref<InboxNotifySettings>({
inboxEnabled: true,
deposit: true,
});
const inboxNotifySaving = ref(false);
const form = ref({
sortOrder: 0,
@@ -200,12 +137,63 @@ function formatTime(v: string | null) {
});
}
const isBanner = computed(() => activeType.value === 'BANNER');
const isAnnouncement = computed(() => activeType.value === 'ANNOUNCEMENT');
const dialogTitle = computed(() =>
editingId.value ? t('content.dialog.edit') : t('content.dialog.create'),
function previewText(row: ContentItem) {
const raw = row.previewTitle || row.translations.find((tr) => tr.body)?.body || '';
return stripHtml(raw) || '—';
}
const isBannerTab = computed(() => activeType.value === 'BANNER');
const isInboxNotifyTab = computed(() => activeType.value === 'INBOX_NOTIFY');
const activeTranslation = computed(
() =>
form.value.translations.find((tr) => tr.locale === activeLocale.value) ??
form.value.translations[0],
);
const bannerDialogTitle = computed(() =>
editingId.value ? t('content.dialog.edit_banner') : t('content.dialog.create_banner'),
);
const announcementDialogTitle = computed(() =>
editingId.value ? t('content.dialog.edit_notice') : t('content.dialog.create_notice'),
);
async function loadInboxNotifySettings() {
try {
const { data } = await api.get('/admin/contents/inbox-notify-settings');
inboxNotifySettings.value = {
inboxEnabled: data.data?.inboxEnabled !== false,
deposit: Boolean(data.data?.deposit),
};
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
}
}
async function saveInboxNotifySettings() {
if (!canManageContent.value) return;
inboxNotifySaving.value = true;
try {
const { data } = await api.put('/admin/contents/inbox-notify-settings', {
inboxEnabled: inboxNotifySettings.value.inboxEnabled,
deposit: inboxNotifySettings.value.deposit,
});
inboxNotifySettings.value = {
inboxEnabled: data.data?.inboxEnabled !== false,
deposit: Boolean(data.data?.deposit),
};
ElMessage.success(t('msg.saved'));
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
await loadInboxNotifySettings();
} finally {
inboxNotifySaving.value = false;
}
}
async function load() {
loading.value = true;
try {
@@ -244,9 +232,18 @@ watch([activeType, filterStatus], () => {
page.value = 1;
selectedRows.value = [];
tableRef.value?.clearSelection();
if (activeType.value === 'INBOX_NOTIFY') {
void loadInboxNotifySettings();
return;
}
void load();
});
onActivated(() => {
if (activeType.value === 'INBOX_NOTIFY') return;
if (items.value.length > 0) void load();
});
function onSelectionChange(rows: ContentItem[]) {
selectedRows.value = rows;
}
@@ -311,6 +308,8 @@ function batchDelete() {
}
function resetForm() {
activeLocale.value = 'zh-CN';
notifyInbox.value = false;
form.value = {
sortOrder: 0,
status: 'DRAFT',
@@ -322,15 +321,7 @@ function resetForm() {
};
}
function openCreate() {
editingId.value = null;
resetForm();
dialogVisible.value = true;
}
function openEdit(row: ContentItem) {
editingId.value = row.id;
editingContentType.value = row.contentType;
function loadRowIntoForm(row: ContentItem, plainBody = false) {
const byLocale = new Map(row.translations.map((tr) => [tr.locale, tr]));
form.value = {
sortOrder: row.sortOrder,
@@ -341,33 +332,57 @@ function openEdit(row: ContentItem) {
endTime: normalizeStartTimeForPicker(row.endTime ?? undefined),
translations: LOCALES.map((locale) => {
const tr = byLocale.get(locale);
const rawBody = tr?.body ?? '';
return {
locale,
title: tr?.title ?? '',
body: tr?.body ?? '',
body: plainBody ? stripHtml(rawBody) : rawBody,
imageUrl: tr?.imageUrl ?? '',
};
}),
};
dialogVisible.value = true;
}
function buildPayload() {
const contentType: StoredContentType = editingId.value
? editingContentType.value
: isBanner.value
? 'BANNER'
: 'NOTICE';
function openCreate() {
editingId.value = null;
resetForm();
if (isBannerTab.value) {
editingContentType.value = 'BANNER';
bannerDialogVisible.value = true;
} else {
editingContentType.value = 'TICKER';
announcementDialogVisible.value = true;
}
}
function openEdit(row: ContentItem) {
editingId.value = row.id;
editingContentType.value = row.contentType;
activeLocale.value = 'zh-CN';
if (row.contentType === 'BANNER') {
loadRowIntoForm(row, false);
bannerDialogVisible.value = true;
} else {
loadRowIntoForm(row, true);
announcementDialogVisible.value = true;
}
}
function buildScheduleFields() {
return {
contentType,
sortOrder: form.value.sortOrder,
status: form.value.status,
linkType: isBanner.value && form.value.linkType ? form.value.linkType : null,
linkTarget:
isBanner.value && form.value.linkType ? form.value.linkTarget.trim() : null,
startTime: form.value.startTime ? normalizeStartTimeForApi(form.value.startTime) : null,
endTime: form.value.endTime ? normalizeStartTimeForApi(form.value.endTime) : null,
};
}
function buildBannerPayload() {
const payload = {
contentType: 'BANNER' as const,
...buildScheduleFields(),
linkType: form.value.linkType ? form.value.linkType : null,
linkTarget: form.value.linkType ? form.value.linkTarget.trim() : null,
translations: form.value.translations.map((tr) => ({
locale: tr.locale,
title: tr.title.trim() || undefined,
@@ -375,25 +390,94 @@ function buildPayload() {
imageUrl: tr.imageUrl.trim() || undefined,
})),
};
if (!editingId.value) {
return { ...payload, notifyInbox: notifyInbox.value };
}
return payload;
}
async function submitForm() {
if (uploadingLocale.value) {
ElMessage.warning(t('content.upload.uploading'));
return;
function buildAnnouncementPayload() {
const contentType: StoredContentType = editingId.value ? editingContentType.value : 'TICKER';
const payload = {
contentType,
...buildScheduleFields(),
linkType: null,
linkTarget: null,
translations: form.value.translations.map((tr) => ({
locale: tr.locale,
title: tr.title.trim() || undefined,
body: tr.body.trim() || undefined,
})),
};
if (!editingId.value) {
return { ...payload, notifyInbox: notifyInbox.value };
}
return payload;
}
async function submitBannerForm() {
saving.value = true;
try {
const payload = buildPayload();
const editor = bannerEditorRef.value;
if (editor) {
activeTranslation.value.body = editor.getHtml();
for (const tr of form.value.translations) {
tr.body = await editor.uploadPendingImages(tr.body);
}
}
const payload = buildBannerPayload();
const isCreate = !editingId.value;
if (editingId.value) {
const { contentType: _type, ...updateBody } = payload;
const { contentType: _type, notifyInbox: _notify, ...updateBody } = payload as ReturnType<typeof buildBannerPayload> & { notifyInbox?: boolean };
await api.put(`/admin/contents/${editingId.value}`, updateBody);
ElMessage.success(t('msg.saved'));
} else {
await api.post('/admin/contents', payload);
const { data } = await api.post('/admin/contents', payload);
const notifiedCount = Number(data.data?.notifiedCount ?? 0);
if (notifyInbox.value && notifiedCount > 0) {
ElMessage.success(t('content.msg.notify_sent', { count: notifiedCount }));
} else {
ElMessage.success(t('msg.saved'));
}
}
ElMessage.success(t('msg.saved'));
dialogVisible.value = false;
bannerDialogVisible.value = false;
if (isCreate) page.value = 1;
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string; message?: string | string[] } } };
const msg = err.response?.data?.error
?? (Array.isArray(err.response?.data?.message)
? err.response?.data?.message.join(', ')
: err.response?.data?.message)
?? (e instanceof Error ? e.message : t('msg.save_failed'));
ElMessage.error(String(msg));
} finally {
saving.value = false;
}
}
async function submitAnnouncementForm() {
saving.value = true;
try {
const payload = buildAnnouncementPayload();
const isCreate = !editingId.value;
if (editingId.value) {
const { contentType: _type, notifyInbox: _notify, ...updateBody } = payload as ReturnType<
typeof buildAnnouncementPayload
> & { notifyInbox?: boolean };
await api.put(`/admin/contents/${editingId.value}`, updateBody);
ElMessage.success(t('msg.saved'));
} else {
const { data } = await api.post('/admin/contents', payload);
const notifiedCount = Number(data.data?.notifiedCount ?? 0);
if (notifyInbox.value && notifiedCount > 0) {
ElMessage.success(t('content.msg.notify_sent', { count: notifiedCount }));
} else {
ElMessage.success(t('msg.saved'));
}
}
announcementDialogVisible.value = false;
if (isCreate) page.value = 1;
await load();
} catch (e: unknown) {
@@ -426,7 +510,7 @@ async function setStatus(row: ContentItem, status: ContentStatus) {
async function removeItem(row: ContentItem) {
try {
await ElMessageBox.confirm(
t('content.confirm_delete', { title: row.previewTitle || row.id }),
t('content.confirm_delete', { title: previewText(row) }),
{ type: 'warning' },
);
} catch {
@@ -459,8 +543,42 @@ void load();
:name="tp"
/>
</el-tabs>
<p v-if="isAnnouncement" class="type-hint">{{ t('content.hint.announcement') }}</p>
<el-form inline class="filter-row">
<template v-if="isInboxNotifyTab">
<div class="inbox-notify-panel">
<div class="inbox-notify-row">
<div class="inbox-notify-row-text">
<span class="inbox-notify-label">{{ t('content.inbox_notify.inbox_enabled') }}</span>
<span class="inbox-notify-hint">{{ t('content.inbox_notify.inbox_enabled_hint') }}</span>
</div>
<el-switch
v-model="inboxNotifySettings.inboxEnabled"
:disabled="!canManageContent || inboxNotifySaving"
@change="saveInboxNotifySettings"
/>
</div>
<div v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-row">
<div class="inbox-notify-row-text">
<span class="inbox-notify-label">{{ t('content.inbox_notify.deposit') }}</span>
<span class="inbox-notify-hint">{{ t('content.inbox_notify.deposit_hint') }}</span>
</div>
<el-switch
v-model="inboxNotifySettings.deposit"
:disabled="!canManageContent || inboxNotifySaving"
@change="saveInboxNotifySettings"
/>
</div>
<p v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes-title">{{ t('content.inbox_notify.manual_title') }}</p>
<ul v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-notes">
<li>{{ t('content.inbox_notify.banner_note') }}</li>
<li>{{ t('content.inbox_notify.announcement_note') }}</li>
</ul>
</div>
</template>
<el-form v-else inline class="filter-row">
<el-form-item :label="t('common.status')">
<el-select v-model="filterStatus" clearable style="width: 140px">
<el-option :label="t('common.all')" value="" />
@@ -478,38 +596,24 @@ void load();
{{ t('content.btn.create') }}
</el-button>
<template v-if="canManageContent">
<span v-if="hasSelection" class="batch-hint">
{{ t('content.batch.selected', { n: selectedRows.length }) }}
</span>
<el-button
size="small"
:disabled="!hasSelection || saving"
@click="batchEnable"
>
{{ t('content.batch.enable') }}
</el-button>
<el-button
size="small"
:disabled="!hasSelection || saving"
@click="batchDisable"
>
{{ t('content.batch.disable') }}
</el-button>
<el-button
type="danger"
plain
size="small"
:disabled="!hasSelection || saving"
@click="batchDelete"
>
{{ t('content.batch.delete') }}
</el-button>
<span v-if="hasSelection" class="batch-hint">
{{ t('content.batch.selected', { n: selectedRows.length }) }}
</span>
<el-button size="small" :disabled="!hasSelection || saving" @click="batchEnable">
{{ t('content.batch.enable') }}
</el-button>
<el-button size="small" :disabled="!hasSelection || saving" @click="batchDisable">
{{ t('content.batch.disable') }}
</el-button>
<el-button type="danger" plain size="small" :disabled="!hasSelection || saving" @click="batchDelete">
{{ t('content.batch.delete') }}
</el-button>
</template>
</el-form-item>
</el-form>
</el-card>
<el-card v-loading="loading" class="data-card" shadow="never">
<el-card v-if="!isInboxNotifyTab" v-loading="loading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table
ref="tableRef"
@@ -523,21 +627,17 @@ void load();
<AdminTableEmpty />
</template>
<el-table-column type="selection" width="44" :selectable="() => !saving" />
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="sortOrder" :label="t('content.col.sort')" width="64" align="center" />
<el-table-column v-if="isBanner" :label="t('content.col.preview')" width="88" align="center">
<el-table-column :label="t('content.col.preview')" width="88" align="center">
<template #default="{ row }">
<img
v-if="row.previewImageUrl"
:src="row.previewImageUrl"
alt=""
class="thumb"
/>
<img v-if="row.previewImageUrl" :src="row.previewImageUrl" alt="" class="thumb" />
<span v-else class="thumb-empty"></span>
</template>
</el-table-column>
<el-table-column :label="t('content.col.title')" min-width="160">
<el-table-column :label="t('content.col.title')" min-width="180">
<template #default="{ row }">
<span class="preview-title">{{ row.previewTitle || '—' }}</span>
<span class="preview-title">{{ previewText(row) }}</span>
<p v-if="!row.playerVisible && row.playerHiddenReason" class="hidden-tip">
{{ hiddenTip(row.playerHiddenReason) }}
</p>
@@ -564,7 +664,7 @@ void load();
<span class="schedule-line">{{ formatTime(row.endTime) }}</span>
</template>
</el-table-column>
<el-table-column v-if="isBanner" :label="t('content.col.link')" min-width="120">
<el-table-column :label="t('content.col.link')" min-width="120">
<template #default="{ row }">
<template v-if="row.linkType">
{{ row.linkType }} · {{ row.linkTarget || '' }}
@@ -586,13 +686,7 @@ void load();
>
{{ t('content.btn.enable') }}
</el-button>
<el-button
v-else
link
type="warning"
:disabled="saving"
@click="setStatus(row, 'INACTIVE')"
>
<el-button v-else link type="warning" :disabled="saving" @click="setStatus(row, 'INACTIVE')">
{{ t('content.btn.disable') }}
</el-button>
<el-button link type="danger" :disabled="saving" @click="removeItem(row)">
@@ -616,134 +710,261 @@ void load();
</div>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="640px" destroy-on-close>
<el-form label-width="96px" size="small">
<el-form-item :label="t('content.col.sort')">
<el-input-number v-model="form.sortOrder" :min="0" :step="1" />
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="form.status" style="width: 160px">
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
<template v-if="isBanner">
<el-form-item :label="t('content.field.link_type')">
<el-select v-model="form.linkType" clearable style="width: 160px">
<el-option :label="t('content.link.none')" value="" />
<el-option label="ROUTE" value="ROUTE" />
<el-option label="URL" value="URL" />
</el-select>
</el-form-item>
<el-form-item v-if="form.linkType" :label="t('content.field.link_target')">
<el-input
v-model="form.linkTarget"
:placeholder="form.linkType === 'ROUTE' ? '/football' : 'https://'"
/>
</el-form-item>
</template>
<el-form-item :label="t('content.field.start_time')">
<el-date-picker
v-model="form.startTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
<el-form-item :label="t('content.field.end_time')">
<el-date-picker
v-model="form.endTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
<el-dialog
v-model="bannerDialogVisible"
:title="bannerDialogTitle"
width="1000px"
class="content-publish-dialog"
destroy-on-close
>
<el-form label-width="84px" size="small" class="publish-form">
<div class="publish-layout">
<aside class="publish-meta">
<section class="publish-section publish-section--compact">
<div class="publish-section-head">{{ t('content.section.publish') }}</div>
<div v-for="tr in form.translations" :key="tr.locale" class="locale-block">
<div class="locale-head">{{ localeLabel(tr.locale) }}</div>
<el-form-item :label="t('content.field.title')">
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
</el-form-item>
<el-form-item
v-if="isBanner"
:label="t('content.field.image_url')"
:required="form.status === 'ACTIVE'"
>
<div class="banner-upload-field">
<p class="banner-size-hint">{{ t('content.upload.recommended_size') }}</p>
<!-- Image preview -->
<div v-if="tr.imageUrl" class="banner-preview">
<img :src="tr.imageUrl" alt="" class="banner-preview-img" />
<button type="button" class="banner-preview-remove" :title="t('content.upload.remove')" @click="removeBannerImage(tr.locale)">&times;</button>
</div>
<!-- Upload actions -->
<div class="banner-upload-actions">
<label
class="banner-upload-btn"
:class="{ 'is-uploading': uploadingLocale === tr.locale }"
<el-row :gutter="8" class="publish-grid">
<el-col :span="12">
<el-form-item :label="t('common.status')" label-position="top" class="publish-field">
<el-select v-model="form.status" style="width: 100%">
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('content.col.sort')" label-position="top" class="publish-field">
<el-input-number
v-model="form.sortOrder"
:min="0"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="8" class="publish-grid">
<el-col :span="12">
<el-form-item :label="t('content.field.start_time')" label-position="top" class="publish-field">
<el-date-picker
v-model="form.startTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="t('content.field.end_time')" label-position="top" class="publish-field">
<el-date-picker
v-model="form.endTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item :label="t('content.field.link_type')" label-position="top" class="publish-field">
<el-select v-model="form.linkType" clearable style="width: 100%">
<el-option :label="t('content.link.none')" value="" />
<el-option label="ROUTE" value="ROUTE" />
<el-option label="URL" value="URL" />
</el-select>
</el-form-item>
<el-form-item
v-if="form.linkType"
:label="t('content.field.link_target')"
label-position="top"
class="publish-field"
>
<el-input
v-model="form.linkTarget"
:placeholder="form.linkType === 'ROUTE' ? '/bet' : 'https://'"
/>
</el-form-item>
<el-form-item v-if="!editingId" label-position="top" class="publish-field notify-inbox-field">
<template #label>{{ t('content.field.notify_inbox') }}</template>
<div class="notify-inbox-block">
<el-switch v-model="notifyInbox" :disabled="form.status !== 'ACTIVE'" />
<p class="notify-inbox-hint">{{ t('content.field.notify_inbox_hint') }}</p>
</div>
</el-form-item>
</section>
<section class="publish-section publish-meta-fields">
<h3 class="section-title">{{ t('content.section.content') }}</h3>
<el-tabs v-model="activeLocale" class="locale-tabs">
<el-tab-pane
v-for="tr in form.translations"
:key="tr.locale"
:label="localeLabel(tr.locale)"
:name="tr.locale"
>
<input
type="file"
:accept="IMAGE_ACCEPT"
style="display: none"
:disabled="uploadingLocale === tr.locale"
@change="onBannerFileChange($event, tr.locale)"
/>
{{ uploadingLocale === tr.locale ? t('content.upload.uploading') : t('content.upload.upload_btn') }}
</label>
<button type="button" class="banner-pick-btn" @click="openMediaPicker(tr.locale)">
{{ t('content.upload.pick_media') }}
</button>
</div>
<!-- Manual URL fallback -->
<el-input
v-model="tr.imageUrl"
:placeholder="t('content.upload.url_placeholder')"
size="small"
class="banner-url-input"
/>
<el-form-item
:label="t('content.field.title')"
:required="form.status === 'ACTIVE'"
>
<el-input v-model="tr.title" :placeholder="t('content.field.title_ph')" />
</el-form-item>
<el-form-item
:label="t('content.field.cover_image')"
:required="form.status === 'ACTIVE'"
>
<ContentImageField
v-model="tr.imageUrl"
category="banners"
size-hint-key="content.upload.recommended_size"
/>
</el-form-item>
</el-tab-pane>
</el-tabs>
</section>
</aside>
<div class="publish-body">
<div class="publish-body-head">
<span class="publish-body-label">
{{ t('content.field.body') }}
<span v-if="form.status === 'ACTIVE'" class="required-mark">*</span>
</span>
<span class="locale-badge">{{ localeLabel(activeLocale) }}</span>
</div>
</el-form-item>
<el-form-item
:label="isAnnouncement ? t('content.field.announce_text') : t('content.field.body')"
:required="isAnnouncement && form.status === 'ACTIVE'"
>
<el-input v-model="tr.body" type="textarea" :rows="isAnnouncement ? 2 : 3" />
</el-form-item>
<ContentRichEditor
ref="bannerEditorRef"
v-model="activeTranslation.body"
fill
upload-category="banners"
:placeholder="t('content.editor.placeholder')"
class="publish-rich-editor"
/>
</div>
</div>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" :disabled="!!uploadingLocale" @click="submitForm">
<el-button @click="bannerDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitBannerForm">
{{ t('common.save') }}
</el-button>
</template>
</el-dialog>
<!-- Media picker dialog -->
<el-dialog v-model="mediaPickerVisible" :title="t('content.upload.pick_media_title')" width="680px" destroy-on-close append-to-body>
<div v-if="mediaLoading" class="media-picker-loading">{{ t('common.loading') }}</div>
<div v-else-if="mediaFiles.length === 0" class="media-picker-empty">{{ t('content.upload.no_media') }}</div>
<div v-else class="media-picker-grid">
<div
v-for="file in mediaFiles"
:key="file.id"
class="media-picker-card"
@click="pickMediaFile(file)"
>
<div class="media-picker-thumb">
<img v-if="file.mimeType !== 'image/svg+xml'" :src="file.url" :alt="file.filename" loading="lazy" />
<div v-else class="media-picker-svg">SVG</div>
</div>
<div class="media-picker-name" :title="file.filename">{{ file.filename }}</div>
</div>
</div>
<el-dialog
v-model="announcementDialogVisible"
:title="announcementDialogTitle"
width="640px"
class="announcement-dialog"
destroy-on-close
>
<p class="announcement-hint">{{ t('content.hint.announcement') }}</p>
<el-form label-width="96px" size="small" class="announcement-form">
<section class="announcement-publish">
<div class="publish-section-head">{{ t('content.section.publish') }}</div>
<el-row :gutter="8" class="announcement-publish-row">
<el-col :xs="12" :sm="6">
<el-form-item :label="t('common.status')" label-position="top" class="publish-field">
<el-select v-model="form.status" style="width: 100%">
<el-option
v-for="st in ['DRAFT', 'ACTIVE', 'INACTIVE']"
:key="st"
:label="statusLabel(st)"
:value="st"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="6">
<el-form-item :label="t('content.col.sort')" label-position="top" class="publish-field">
<el-input-number
v-model="form.sortOrder"
:min="0"
:step="1"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="6">
<el-form-item :label="t('content.field.start_time')" label-position="top" class="publish-field">
<el-date-picker
v-model="form.startTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :xs="12" :sm="6">
<el-form-item :label="t('content.field.end_time')" label-position="top" class="publish-field">
<el-date-picker
v-model="form.endTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
clearable
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="!editingId" label-position="top" class="publish-field notify-inbox-field">
<template #label>{{ t('content.field.notify_inbox') }}</template>
<div class="notify-inbox-block">
<el-switch v-model="notifyInbox" :disabled="form.status !== 'ACTIVE'" />
<p class="notify-inbox-hint">{{ t('content.field.notify_inbox_hint') }}</p>
</div>
</el-form-item>
</section>
<section class="announcement-content">
<h3 class="section-title">{{ t('content.section.content') }}</h3>
<el-tabs v-model="activeLocale" class="locale-tabs">
<el-tab-pane
v-for="tr in form.translations"
:key="tr.locale"
:label="localeLabel(tr.locale)"
:name="tr.locale"
>
<el-form-item :label="t('content.field.title')">
<el-input v-model="tr.title" :placeholder="t('content.field.ticker_title_ph')" />
</el-form-item>
<el-form-item
:label="t('content.field.announce_text')"
:required="form.status === 'ACTIVE'"
>
<el-input
v-model="tr.body"
type="textarea"
:rows="4"
:placeholder="t('content.field.ticker_body_ph')"
/>
<p class="field-hint compact-hint">{{ t('content.field.ticker_hint') }}</p>
</el-form-item>
</el-tab-pane>
</el-tabs>
</section>
</el-form>
<template #footer>
<el-button @click="announcementDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="saving" @click="submitAnnouncementForm">
{{ t('common.save') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -753,11 +974,27 @@ void load();
margin-bottom: 12px;
}
.type-hint {
margin: 0 0 8px;
font-size: 12px;
color: #666;
line-height: 1.5;
.field-hint,
.batch-hint,
.schedule-line,
.schedule-sep,
.thumb-empty {
color: var(--text-muted);
}
.field-hint {
margin: 4px 0 0;
font-size: 11px;
line-height: 1.35;
}
.field-hint.inline-hint,
.field-hint.compact-hint {
margin-top: 0;
}
.field-hint.inline-hint {
margin-left: 8px;
}
.filter-row {
@@ -766,7 +1003,6 @@ void load();
.batch-hint {
font-size: 12px;
color: #888;
margin: 0 4px;
white-space: nowrap;
}
@@ -776,313 +1012,257 @@ void load();
height: 32px;
object-fit: cover;
border-radius: 4px;
background: #222;
}
.thumb-empty {
color: #555;
font-size: 12px;
background: #f4f0e8;
}
.preview-title {
font-size: 13px;
color: #ccc;
color: var(--text);
font-weight: 700;
}
.hidden-tip {
margin: 4px 0 0;
font-size: 11px;
color: #c9a227;
color: var(--warning-text);
}
.schedule-line {
font-size: 11px;
color: #888;
}
.schedule-sep {
margin: 0 4px;
color: #555;
font-size: 11px;
}
.locale-block {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid #252525;
border-radius: 8px;
background: rgba(255, 255, 255, 0.02);
.content-publish-dialog :deep(.el-dialog__body) {
padding-top: 10px;
padding-bottom: 12px;
}
.locale-head {
font-size: 12px;
font-weight: 700;
color: #888;
margin-bottom: 8px;
.content-publish-dialog :deep(.el-dialog__footer) {
padding-top: 10px;
}
/* ── Banner image upload widget ── */
.banner-upload-field {
.publish-layout {
display: flex;
gap: 14px;
align-items: stretch;
min-height: 480px;
}
.publish-meta {
flex: 0 0 380px;
max-width: 400px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
gap: 10px;
}
.banner-size-hint {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: #777;
.publish-meta-fields {
flex: 1;
min-height: 0;
}
.banner-preview {
position: relative;
width: 100%;
max-width: 320px;
aspect-ratio: 43 / 18;
.publish-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
min-height: 480px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
border: 1px solid #252525;
background: #111;
}
.banner-preview-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.banner-preview-remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border: 1px solid #333;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.banner-preview-remove:hover {
background: rgba(224, 85, 85, 0.85);
}
.banner-upload-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.banner-upload-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
border: 1px solid rgba(212, 175, 55, 0.5);
background: rgba(212, 175, 55, 0.1);
color: var(--gold-text);
font-weight: 600;
transition: all 0.15s;
}
.banner-upload-btn:hover {
background: rgba(212, 175, 55, 0.2);
}
.banner-upload-btn.is-uploading {
opacity: 0.6;
cursor: wait;
}
.banner-pick-btn {
display: inline-flex;
align-items: center;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
border: 1px solid #2a2a2a;
background: transparent;
color: #888;
transition: all 0.15s;
}
.banner-pick-btn:hover {
border-color: #444;
color: #ccc;
}
.banner-url-input {
max-width: 320px;
}
.banner-url-input :deep(.el-input__wrapper) {
font-size: 12px;
}
/* ── Media picker ── */
.media-picker-loading,
.media-picker-empty {
text-align: center;
padding: 40px 0;
color: #555;
font-size: 14px;
}
.media-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 12px;
max-height: 420px;
overflow-y: auto;
}
.media-picker-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid #1e1e1e;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.media-picker-card:hover {
border-color: rgba(212, 175, 55, 0.5);
box-shadow: 0 2px 12px rgba(212, 175, 55, 0.15);
}
.media-picker-thumb {
height: 80px;
background: #111;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.media-picker-thumb img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.media-picker-svg {
font-size: 14px;
font-weight: 700;
color: #666;
letter-spacing: 0.1em;
}
.media-picker-name {
padding: 6px 8px;
font-size: 11px;
color: #888;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contents-page {
color: var(--text);
}
.type-hint,
.batch-hint,
.banner-size-hint,
.schedule-line,
.schedule-sep,
.media-picker-loading,
.media-picker-empty,
.media-picker-name,
.thumb-empty,
.locale-head {
color: var(--text-muted);
}
.preview-title {
color: var(--text);
font-weight: 700;
}
.hidden-tip {
color: var(--warning-text);
}
.thumb,
.banner-preview,
.media-picker-thumb {
background: #f4f0e8;
}
.banner-preview,
.locale-block {
border-color: var(--border);
background: #fbfaf7;
}
.banner-preview-remove {
border-color: rgba(255, 255, 255, 0.36);
background: rgba(31, 35, 32, 0.78);
.publish-body-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.banner-preview-remove:hover {
background: rgba(159, 47, 45, 0.92);
}
.banner-upload-btn,
.banner-pick-btn {
border-radius: 7px;
.publish-body-label {
font-size: 13px;
font-weight: 700;
}
.banner-upload-btn {
border-color: var(--primary);
background: var(--primary);
color: #ffffff;
}
.banner-upload-btn:hover {
background: var(--primary-light);
}
.banner-pick-btn {
border-color: var(--border);
background: #ffffff;
color: var(--text);
}
.banner-pick-btn:hover {
border-color: #d5cfc3;
background: var(--accent-hover);
color: var(--text);
.required-mark {
color: var(--el-color-danger);
margin-left: 2px;
}
.media-picker-card {
border-color: var(--border);
background: #ffffff;
}
.media-picker-card:hover {
border-color: #d5cfc3;
box-shadow: 0 8px 22px rgba(56, 49, 37, 0.08);
}
.media-picker-svg {
.locale-badge {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 4px;
background: #fff;
white-space: nowrap;
}
.publish-rich-editor {
flex: 1;
min-height: 0;
}
.publish-form :deep(.el-form-item) {
margin-bottom: 8px;
}
.publish-form :deep(.el-form-item__label) {
padding-right: 8px;
line-height: 28px;
}
.publish-section {
margin-bottom: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
.publish-section--compact {
padding: 10px 12px 8px;
}
.publish-section:last-child {
margin-bottom: 0;
}
.section-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.publish-section-head {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text-muted);
}
.publish-section--compact :deep(.publish-field.el-form-item) {
margin-bottom: 12px;
}
.publish-section--compact :deep(.publish-field.el-form-item:last-child) {
margin-bottom: 0;
}
.publish-section--compact :deep(.publish-field .el-form-item__label) {
padding: 0 0 4px;
line-height: 1.35;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
}
.publish-section--compact :deep(.publish-field .el-form-item__content) {
line-height: normal;
}
.publish-grid {
margin-bottom: 12px;
}
.publish-grid :deep(.publish-field.el-form-item) {
margin-bottom: 0;
}
.notify-inbox-block {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.notify-inbox-hint {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
word-break: break-word;
}
.notify-inbox-field {
margin-top: 4px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.publish-grid--secondary {
margin-top: 2px;
}
.announcement-dialog :deep(.el-dialog__body) {
padding-top: 8px;
}
.announcement-hint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted);
}
.announcement-publish {
margin-bottom: 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
.announcement-content {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
.announcement-form :deep(.el-form-item) {
margin-bottom: 10px;
}
.announcement-publish-row {
margin-top: 4px;
}
@media (max-width: 900px) {
.publish-layout {
flex-direction: column;
min-height: 0;
}
.publish-meta {
flex: none;
max-width: none;
width: 100%;
}
.publish-body {
min-height: 420px;
}
}
.locale-tabs :deep(.el-tabs__header) {
margin-bottom: 6px;
}
.locale-tabs :deep(.el-tabs__content) {
padding-top: 0;
}
@media (max-width: 700px) {
@@ -1091,10 +1271,56 @@ void load();
flex-wrap: wrap;
gap: 8px;
}
}
.banner-preview,
.banner-url-input {
max-width: 100%;
}
.inbox-notify-panel {
padding: 4px 0 12px;
max-width: 420px;
}
.inbox-notify-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.inbox-notify-row-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.inbox-notify-label {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.inbox-notify-hint {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.inbox-notify-notes-title {
margin: 14px 0 6px;
font-size: 12px;
color: var(--text-muted);
}
.inbox-notify-notes {
margin: 0 0 16px;
padding-left: 18px;
font-size: 13px;
color: var(--text);
line-height: 1.8;
}
.inbox-notify-notes li {
margin: 0;
}
</style>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
defineOptions({ name: 'AdminDepositManage' });
import { ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
@@ -44,8 +46,8 @@ function switchTab(key: string) {
</button>
</div>
<DepositOrders v-if="activeTab === 'orders'" />
<PaymentMethods v-if="activeTab === 'methods'" />
<DepositOrders v-show="activeTab === 'orders'" />
<PaymentMethods v-show="activeTab === 'methods'" />
</div>
</template>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, onMounted, onActivated } from 'vue';
defineOptions({ name: 'AdminDepositOrders' });
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveApiError } from '../i18n/form-validation';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { formatAmount } from '../utils/format-amount';
import { refreshDepositPendingCount } from '../composables/useDepositPendingCount';
const { t } = useAdminLocale();
@@ -45,7 +48,7 @@ interface DepositAuditLogRow {
const items = ref<DepositOrderRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const pageSize = ref(10);
const loading = ref(false);
// Filters
@@ -74,8 +77,8 @@ const auditTarget = ref<DepositOrderRow | null>(null);
const auditLogs = ref<DepositAuditLogRow[]>([]);
const auditLoading = ref(false);
async function fetchList() {
loading.value = true;
async function fetchList(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const params: any = { page: page.value, pageSize: pageSize.value };
if (statusFilter.value) params.status = statusFilter.value;
@@ -85,8 +88,9 @@ async function fetchList() {
const result = data.data ?? { items: [], total: 0 };
items.value = result.items ?? [];
total.value = result.total ?? 0;
void refreshDepositPendingCount();
} catch { /* */ } finally {
loading.value = false;
if (!opts?.silent) loading.value = false;
}
}
@@ -238,14 +242,21 @@ function statusLabel(s: string) {
return '● ' + t('deposit.status_pending');
}
function refreshList() {
void fetchList();
}
function prevPage() { if (page.value > 1) { page.value--; fetchList(); } }
function nextPage() { if (page.value * pageSize.value < total.value) { page.value++; fetchList(); } }
onMounted(fetchList);
onMounted(() => void fetchList());
onActivated(() => {
if (items.value.length > 0) void fetchList({ silent: true });
});
</script>
<template>
<div class="page-deposit-orders">
<div v-loading="loading" class="page-deposit-orders">
<div class="toolbar">
<h2>{{ t('deposit.deposit_orders_title') }}</h2>
</div>
@@ -268,6 +279,9 @@ onMounted(fetchList);
@keydown.enter="page = 1; fetchList()"
/>
<button class="btn-search" @click="page = 1; fetchList()">{{ t('common.search') }}</button>
<button class="btn-refresh" type="button" :disabled="loading" @click="refreshList">
{{ t('common.refresh') }}
</button>
</div>
<AdminTableEmpty v-if="!loading && !items.length" />
@@ -275,6 +289,7 @@ onMounted(fetchList);
<table v-if="items.length" class="data-table">
<thead>
<tr>
<th style="width: 60px; text-align: center;">{{ t('common.seq') }}</th>
<th>{{ t('deposit.order_no') }}</th>
<th>{{ t('deposit.player') }}</th>
<th>{{ t('common.type') }}</th>
@@ -289,7 +304,8 @@ onMounted(fetchList);
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id">
<tr v-for="(row, idx) in items" :key="row.id">
<td style="text-align: center;">{{ (page - 1) * pageSize + idx + 1 }}</td>
<td class="mono">{{ row.orderNo }}</td>
<td>{{ row.playerUsername || row.playerId }}</td>
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
@@ -445,6 +461,24 @@ onMounted(fetchList);
.filters select, .filters input { padding: 6px 10px; border-radius: 4px; border: 1px solid #444; background: #1e1e1e; color: #eee; font-size: 13px; }
.filters input { min-width: 160px; }
.btn-search { background: #409eff; color: #fff; border: none; border-radius: 4px; padding: 6px 14px; cursor: pointer; font-weight: 600; font-size: 13px; }
.btn-refresh {
background: #fff;
color: #606266;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 6px 14px;
cursor: pointer;
font-weight: 600;
font-size: 13px;
}
.btn-refresh:hover:not(:disabled) {
border-color: #409eff;
color: #409eff;
}
.btn-refresh:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.data-table th, .data-table td { padding: 10px 6px; border-bottom: 1px solid #333; text-align: left; }
.data-table th { font-weight: 700; color: #aaa; font-size: 11px; text-transform: uppercase; }

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, onActivated, watch } from 'vue';
defineOptions({ name: 'AdminFinanceLogs' });
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { useAdminLocale } from '../composables/useAdminLocale';
@@ -47,12 +49,14 @@ interface TransferTxRow {
const creditItems = ref<CreditTxRow[]>([]);
const creditTotal = ref(0);
const creditPage = ref(1);
const creditPageSize = ref(20);
const creditPageSize = ref(10);
const creditLoading = ref(false);
const transferItems = ref<TransferTxRow[]>([]);
const transferTotal = ref(0);
const transferPage = ref(1);
const transferPageSize = ref(20);
const transferPageSize = ref(10);
const transferLoading = ref(false);
const keyword = ref('');
const agentId = ref('');
@@ -123,7 +127,9 @@ function dateParams() {
};
}
async function loadCredit() {
async function loadCredit(opts?: { silent?: boolean }) {
if (!opts?.silent) creditLoading.value = true;
try {
const { data } = await api.get(creditApiPath.value, {
params: {
page: creditPage.value,
@@ -137,9 +143,14 @@ async function loadCredit() {
});
creditItems.value = (data.data?.items ?? []) as CreditTxRow[];
creditTotal.value = data.data?.total ?? 0;
} finally {
if (!opts?.silent) creditLoading.value = false;
}
}
async function loadTransfer() {
async function loadTransfer(opts?: { silent?: boolean }) {
if (!opts?.silent) transferLoading.value = true;
try {
const parentRaw = parentAgentKeyword.value.trim();
const parentIsId = parentRaw && /^\d+$/.test(parentRaw);
const { data } = await api.get(transferApiPath.value, {
@@ -155,6 +166,9 @@ async function loadTransfer() {
});
transferItems.value = (data.data?.items ?? []) as TransferTxRow[];
transferTotal.value = data.data?.total ?? 0;
} finally {
if (!opts?.silent) transferLoading.value = false;
}
}
function onSearch() {
@@ -204,6 +218,10 @@ onMounted(() => {
if (activeTab.value === 'credit') void loadCredit();
else void loadTransfer();
});
onActivated(() => {
if (activeTab.value === 'credit' && creditItems.value.length > 0) void loadCredit({ silent: true });
else if (activeTab.value === 'transfer' && transferItems.value.length > 0) void loadTransfer({ silent: true });
});
watch(
() => route.query.agentId,
@@ -314,12 +332,13 @@ watch(
</el-form>
</el-card>
<el-card v-show="activeTab === 'credit'" class="data-card" shadow="never">
<el-card v-show="activeTab === 'credit'" v-loading="creditLoading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="`${locale}-credit`" :data="creditItems" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="index" :index="(i: number) => (creditPage - 1) * creditPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column :label="t('audit.col.time')" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
@@ -384,12 +403,13 @@ watch(
</div>
</el-card>
<el-card v-show="activeTab === 'transfer'" class="data-card" shadow="never">
<el-card v-show="activeTab === 'transfer'" v-loading="transferLoading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :key="`${locale}-transfer`" :data="transferItems" stripe>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="index" :index="(i: number) => (transferPage - 1) * transferPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column :label="t('audit.col.time')" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>

View File

@@ -3,7 +3,6 @@ import { shallowRef, onBeforeMount, type Component } from 'vue';
import { RouterView, RouterLink, useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuthStore } from '../stores/auth';
import { ensureStaffSession } from '../utils/session-hydrate';
import DashboardSubNav from '../components/DashboardSubNav.vue';
import { usePermissions } from '../composables/usePermissions';
import { AdminPerm } from '../constants/permissions';
@@ -14,12 +13,11 @@ const router = useRouter();
const { t } = useAdminLocale();
const { hasPermission, role } = usePermissions();
const agentDashboard = shallowRef<Component | null>(null);
const booting = shallowRef(true);
const showSupportShortcuts = shallowRef(false);
onBeforeMount(async () => {
await ensureStaffSession();
// router beforeEach 已执行过 ensureStaffSession此处仅做菜单路由重定向逻辑不再重复远程请求
if (auth.isAdmin.value) {
const code = role.value;
if (code === 'FINANCE_ADMIN' && route.path === '/') {
@@ -37,13 +35,11 @@ onBeforeMount(async () => {
} else {
agentDashboard.value = (await import('./agent/Dashboard.vue')).default;
}
booting.value = false;
});
</script>
<template>
<div v-if="booting" v-loading="true" class="home-boot" />
<template v-else-if="auth.isAdmin.value">
<template v-if="auth.isAdmin.value">
<div v-if="showSupportShortcuts" class="support-shortcuts">
<RouterLink v-if="hasPermission(AdminPerm.usersView, AdminPerm.agentsView)" to="/users" class="support-link">
{{ t('nav.agents_players') }}

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed, onBeforeUnmount } from 'vue';
defineOptions({ name: 'AdminMatches' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import { useRoute } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
@@ -108,19 +111,21 @@ async function load(options: LoadOptions = {}) {
function onSearch() {
page.value = 1;
expandedRowKeys.value = [];
load();
void runLoad(true);
}
onMounted(() => {
async function initialLoad() {
const qStatus = route.query.status;
if (typeof qStatus === 'string' && qStatus.trim()) {
filterStatus.value = qStatus.trim();
page.value = 1;
load();
await load();
return;
}
load({ restoreExpand: true });
});
await load({ restoreExpand: true });
}
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad);
onBeforeUnmount(persistListUiState);
function onPageChange(p: number) {
@@ -417,7 +422,7 @@ function onLeagueArchived() {
<p v-if="filterStatus" class="list-hint">{{ t('match.filter.status_hint') }}</p>
</div>
<section class="list-panel">
<section v-loading="listLoading" class="list-panel">
<p class="list-hint">{{ t('match.expand_league_hint') }}</p>
<div class="table-wrap">
<el-table
@@ -437,12 +442,11 @@ function onLeagueArchived() {
:filter-status="filterStatus"
:keyword="keyword"
@changed="() => load({ keepExpand: true })"
@add-match="openCreateFixture(row)"
/>
</template>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
<template #default="{ row }">
<div class="league-cell">
@@ -490,7 +494,7 @@ function onLeagueArchived() {
<el-table-column :label="t('match.col.league_code')" width="120" show-overflow-tooltip>
<template #default="{ row }">{{ rowOf(row).code }}</template>
</el-table-column>
<el-table-column width="168" align="center" fixed="right">
<el-table-column width="280" align="center" fixed="right">
<template #header>
<div class="actions-col-header">
<span class="actions-col-header__label">{{ t('common.actions') }}</span>
@@ -502,6 +506,9 @@ function onLeagueArchived() {
<el-button size="small" type="primary" @click.stop="openEditLeague(row)">
{{ t('common.edit') }}
</el-button>
<el-button size="small" type="primary" @click.stop="openCreateFixture(row)">
{{ t('match.create_fixture_btn') }}
</el-button>
<el-button
v-if="!leagueIsPublished(row)"
size="small"

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { ref, onBeforeUnmount } from 'vue';
defineOptions({ name: 'AdminMatchesOutrights' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import { useRoute } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
@@ -93,10 +96,12 @@ async function resolveExpandFromQuery() {
}
}
onMounted(async () => {
async function initialLoad() {
await load({ restoreExpand: true });
await resolveExpandFromQuery();
});
}
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad);
onBeforeUnmount(persistListUiState);
function onPageChange(p: number) {
@@ -173,7 +178,7 @@ function isLeagueExpanded(id: string) {
</div>
</div>
<section class="list-panel">
<section v-loading="listLoading" class="list-panel">
<p class="list-hint">{{ t('match.expand_outright_hint') }}</p>
<div class="table-wrap">
<el-table
@@ -194,7 +199,7 @@ function isLeagueExpanded(id: string) {
/>
</template>
</el-table-column>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column :label="t('match.col.league')" width="148" show-overflow-tooltip>
<template #default="{ row }">
<div class="league-cell">

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
defineOptions({ name: 'AdminMediaLibrary' });
import { ref, computed, onMounted, onActivated, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
@@ -54,8 +56,8 @@ function formatDate(iso: string) {
return new Date(iso).toLocaleString();
}
async function loadFiles() {
loading.value = true;
async function loadFiles(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const params: Record<string, string | number> = { page: currentPage.value, pageSize };
if (activeCategory.value) params.category = activeCategory.value;
@@ -65,7 +67,7 @@ async function loadFiles() {
} catch {
ElMessage.error(t('common.loading'));
} finally {
loading.value = false;
if (!opts?.silent) loading.value = false;
}
}
@@ -74,9 +76,14 @@ watch(activeCategory, () => {
loadFiles();
});
watch(currentPage, loadFiles);
watch(currentPage, () => {
void loadFiles();
});
onMounted(loadFiles);
onMounted(() => void loadFiles());
onActivated(() => {
if (files.value.length > 0) void loadFiles({ silent: true });
});
async function confirmDelete(file: MediaFile) {
await ElMessageBox.confirm(t('media.delete_confirm'), { type: 'warning' });

View File

@@ -196,6 +196,7 @@ onMounted(fetchList);
<table v-if="filteredItems.length" class="data-table">
<thead>
<tr>
<th style="text-align: center; width: 60px;">{{ t('common.seq') }}</th>
<th>{{ t('common.type') }}</th>
<th>{{ t('deposit.display_name') }}</th>
<th>{{ t('deposit.details') }}</th>
@@ -205,7 +206,8 @@ onMounted(fetchList);
</tr>
</thead>
<tbody>
<tr v-for="row in filteredItems" :key="row.id">
<tr v-for="(row, index) in filteredItems" :key="row.id">
<td style="text-align: center;">{{ index + 1 }}</td>
<td><span :class="['badge', row.methodType === 'BANK' ? 'badge-blue' : 'badge-green']">{{ row.methodType }}</span></td>
<td>{{ row.displayName || '-' }}</td>
<td class="details-cell">

View File

@@ -851,6 +851,7 @@ onMounted(() => {
</span>
</div>
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
<el-table-column type="index" :index="(i: number) => (previewItemsPage.page - 1) * previewItemsPage.pageSize + i + 1" :label="t('common.seq')" width="55" align="center" />
<el-table-column :label="t('bet.col.bet_no')" prop="betNo" min-width="140" show-overflow-tooltip />
<el-table-column :label="t('common.type')" width="68">
<template #default="{ row }">{{ betTypeLabel(row.betType) }}</template>
@@ -935,6 +936,7 @@ onMounted(() => {
stripe
class="stats-table"
>
<el-table-column type="index" :index="(i: number) => ((stats?.bets.page ?? 1) - 1) * (stats?.bets.pageSize ?? 10) + i + 1" :label="t('common.seq')" width="55" align="center" />
<el-table-column prop="betNo" :label="t('bet.col.bet_no')" width="140" />
<el-table-column prop="username" :label="t('bet.col.player')" width="96" />
<el-table-column :label="t('common.type')" width="72">

View File

@@ -247,6 +247,7 @@ onMounted(async () => {
<span v-else class="case-details-empty">{{ t('smoke.no_steps') }}</span>
</template>
</el-table-column>
<el-table-column type="index" :label="t('common.seq')" width="55" align="center" />
<el-table-column prop="id" :label="t('smoke.col.id')" width="88" />
<el-table-column :label="t('smoke.col.suite')" width="120">
<template #default="{ row }">{{ suiteName(row.suite) }}</template>

View File

@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, onActivated, ref } from 'vue';
defineOptions({ name: 'AdminStaffManage' });
import { ElMessage, ElMessageBox } from 'element-plus';
import api from '../api';
import { useAdminLocale } from '../composables/useAdminLocale';
import { useAuthStore } from '../stores/auth';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import AdminTableWrap from '../components/AdminTableWrap.vue';
@@ -14,6 +17,7 @@ interface StaffRow {
roleName: string | null;
lastLoginAt: string | null;
createdAt: string;
visibleMenus: string | null;
}
interface RoleOption {
@@ -23,22 +27,46 @@ interface RoleOption {
}
const { t, localeTag } = useAdminLocale();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.user.value?.id);
const loading = ref(false);
const rows = ref<StaffRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const pageSize = ref(10);
const keyword = ref('');
const roles = ref<RoleOption[]>([]);
const menuOptions = [
{ key: 'dashboard', label: 'nav.dashboard' },
{ key: 'matches', label: 'nav.matches' },
{ key: 'users', label: 'nav.agents_players' },
{ key: 'finance-logs', label: 'nav.finance_logs' },
{ key: 'deposit', label: 'nav.deposit_manage' },
{ key: 'cashback', label: 'nav.cashback' },
{ key: 'bets', label: 'nav.bets' },
{ key: 'contents', label: 'nav.contents' },
{ key: 'media', label: 'nav.media' },
{ key: 'audit', label: 'nav.audit' },
{ key: 'staff', label: 'nav.staff' },
{ key: 'smoke-tests', label: 'nav.smoke_tests' },
];
const ROLE_DEFAULT_MENUS: Record<string, string[]> = {
SUPER_ADMIN: ['dashboard', 'matches', 'users', 'finance-logs', 'deposit', 'cashback', 'bets', 'contents', 'media', 'audit', 'staff', 'smoke-tests'],
MATCH_ADMIN: ['matches', 'bets', 'contents', 'media'],
FINANCE_ADMIN: ['dashboard', 'users', 'finance-logs', 'deposit', 'cashback', 'bets'],
SUPPORT: ['users', 'bets', 'contents', 'media'],
};
const createVisible = ref(false);
const createLoading = ref(false);
const createForm = ref({ username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN' });
const createForm = ref({ username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN', checkedMenus: [] as string[] });
const editVisible = ref(false);
const editLoading = ref(false);
const editForm = ref({ id: '', username: '', status: 'ACTIVE', roleCode: 'MATCH_ADMIN', password: '' });
const editForm = ref({ id: '', username: '', status: 'ACTIVE', roleCode: 'MATCH_ADMIN', password: '', checkedMenus: [] as string[] });
const roleLabel = computed(() => {
const map: Record<string, string> = {
@@ -60,8 +88,8 @@ async function loadRoles() {
roles.value = (data.data ?? []) as RoleOption[];
}
async function load() {
loading.value = true;
async function load(opts?: { silent?: boolean }) {
if (!opts?.silent) loading.value = true;
try {
const { data } = await api.get('/admin/staff', {
params: {
@@ -76,12 +104,22 @@ async function load() {
rows.value = [];
total.value = 0;
} finally {
loading.value = false;
if (!opts?.silent) loading.value = false;
}
}
function onCreateRoleChange(newRole: string) {
createForm.value.checkedMenus = [...(ROLE_DEFAULT_MENUS[newRole] || [])];
}
function openCreate() {
createForm.value = { username: '', password: '', confirmPassword: '', roleCode: 'MATCH_ADMIN' };
createForm.value = {
username: '',
password: '',
confirmPassword: '',
roleCode: 'MATCH_ADMIN',
checkedMenus: [...ROLE_DEFAULT_MENUS['MATCH_ADMIN']],
};
createVisible.value = true;
}
@@ -105,6 +143,7 @@ async function submitCreate() {
username: f.username.trim(),
password: f.password,
roleCode: f.roleCode,
visibleMenus: f.checkedMenus.join(','),
});
ElMessage.success(t('msg.saved'));
createVisible.value = false;
@@ -117,6 +156,10 @@ async function submitCreate() {
}
}
function onEditRoleChange(newRole: string) {
editForm.value.checkedMenus = [...(ROLE_DEFAULT_MENUS[newRole] || [])];
}
function openEdit(row: StaffRow) {
editForm.value = {
id: row.id,
@@ -124,6 +167,9 @@ function openEdit(row: StaffRow) {
status: row.status,
roleCode: row.role ?? 'MATCH_ADMIN',
password: '',
checkedMenus: row.visibleMenus
? row.visibleMenus.split(',').filter(Boolean)
: [...(ROLE_DEFAULT_MENUS[row.role ?? 'MATCH_ADMIN'] || [])],
};
editVisible.value = true;
}
@@ -139,6 +185,7 @@ async function submitEdit() {
const payload: Record<string, string> = {
status: f.status,
roleCode: f.roleCode,
visibleMenus: f.checkedMenus.join(','),
};
if (f.password.trim()) payload.password = f.password.trim();
const { data } = await api.patch(`/admin/staff/${f.id}`, payload);
@@ -157,10 +204,56 @@ async function submitEdit() {
}
}
async function toggleFreeze(row: StaffRow, targetStatus: string) {
const actionText = targetStatus === 'SUSPENDED' ? t('common.freeze') : t('common.unfreeze');
try {
await ElMessageBox.confirm(
`确定要${actionText}管理员「${row.username}」吗?`,
t('msg.freeze_confirm_title', { action: actionText }),
{ type: 'warning' },
);
} catch {
return;
}
try {
await api.patch(`/admin/staff/${row.id}`, { status: targetStatus });
ElMessage.success(t('msg.saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
}
}
async function deleteStaff(row: StaffRow) {
try {
await ElMessageBox.confirm(
`确定要删除管理员「${row.username}」吗?此操作无法撤销。`,
t('common.delete'),
{ type: 'warning' },
);
} catch {
return;
}
try {
await api.delete(`/admin/staff/${row.id}`);
ElMessage.success(t('msg.saved'));
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
}
}
onMounted(async () => {
await loadRoles();
await load();
});
onActivated(() => {
if (rows.value.length > 0) void load({ silent: true });
});
</script>
<template>
@@ -191,19 +284,61 @@ onMounted(async () => {
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="index" :index="(i: number) => (page - 1) * pageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column prop="username" :label="t('login.username')" min-width="120" />
<el-table-column :label="t('staff.col.role')" min-width="140">
<template #default="{ row }">{{ roleLabel(row.role) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="100">
<template #default="{ row }">{{ row.status }}</template>
<el-table-column :label="t('common.status')" width="100" align="center">
<template #default="{ row }">
<el-tag
size="small"
:type="row.status === 'ACTIVE' ? 'success' : row.status === 'SUSPENDED' ? 'warning' : 'danger'"
effect="dark"
>
{{ t(`user.status.${row.status}`) }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('staff.col.last_login')" min-width="160">
<template #default="{ row }">{{ formatTime(row.lastLoginAt) }}</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="100" align="center">
<el-table-column :label="t('common.actions')" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">{{ t('common.edit') }}</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">
{{ t('common.edit') }}
</el-button>
<el-button
v-if="row.status === 'ACTIVE'"
link
type="warning"
size="small"
:disabled="row.id === currentUserId"
@click="toggleFreeze(row, 'SUSPENDED')"
>
{{ t('common.freeze') }}
</el-button>
<el-button
v-else
link
type="success"
size="small"
:disabled="row.id === currentUserId"
@click="toggleFreeze(row, 'ACTIVE')"
>
{{ t('common.unfreeze') }}
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="row.id === currentUserId"
@click="deleteStaff(row)"
>
{{ t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
@@ -229,7 +364,7 @@ onMounted(async () => {
<el-input v-model="createForm.username" autocomplete="off" />
</el-form-item>
<el-form-item :label="t('staff.col.role')" required>
<el-select v-model="createForm.roleCode" style="width: 100%">
<el-select v-model="createForm.roleCode" style="width: 100%" @change="onCreateRoleChange">
<el-option
v-for="r in roles.filter((x) => x.code !== 'SUPER_ADMIN')"
:key="r.code"
@@ -238,6 +373,19 @@ onMounted(async () => {
/>
</el-select>
</el-form-item>
<el-form-item :label="t('staff.field.visible_menus')">
<el-checkbox-group v-model="createForm.checkedMenus">
<div class="menu-grid">
<el-checkbox
v-for="item in menuOptions"
:key="item.key"
:label="item.key"
>
{{ t(item.label) }}
</el-checkbox>
</div>
</el-checkbox-group>
</el-form-item>
<el-form-item :label="t('login.password')" required>
<el-input v-model="createForm.password" type="password" autocomplete="new-password" />
</el-form-item>
@@ -257,7 +405,7 @@ onMounted(async () => {
<el-input :model-value="editForm.username" disabled />
</el-form-item>
<el-form-item :label="t('staff.col.role')">
<el-select v-model="editForm.roleCode" style="width: 100%" :disabled="editForm.roleCode === 'SUPER_ADMIN'">
<el-select v-model="editForm.roleCode" style="width: 100%" :disabled="editForm.roleCode === 'SUPER_ADMIN'" @change="onEditRoleChange">
<el-option
v-for="r in roles"
:key="r.code"
@@ -266,11 +414,24 @@ onMounted(async () => {
/>
</el-select>
</el-form-item>
<el-form-item :label="t('staff.field.visible_menus')">
<el-checkbox-group v-model="editForm.checkedMenus">
<div class="menu-grid">
<el-checkbox
v-for="item in menuOptions"
:key="item.key"
:label="item.key"
>
{{ t(item.label) }}
</el-checkbox>
</div>
</el-checkbox-group>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="editForm.status" style="width: 100%">
<el-option label="ACTIVE" value="ACTIVE" />
<el-option label="SUSPENDED" value="SUSPENDED" />
<el-option label="DISABLED" value="DISABLED" />
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
<el-option :label="t('user.status.DISABLED')" value="DISABLED" />
</el-select>
</el-form-item>
<el-form-item :label="t('user.field.reset_password')">
@@ -291,4 +452,10 @@ onMounted(async () => {
display: flex;
justify-content: flex-end;
}
.menu-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 12px;
width: 100%;
}
</style>

View File

@@ -27,6 +27,7 @@ import {
shouldCompactAmount as shouldCompact,
} from '../utils/format-amount';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import AdminPlayerStatusCell from '../components/AdminPlayerStatusCell.vue';
import AdminDetailGrid from '../components/AdminDetailGrid.vue';
import AdminDetailItem from '../components/AdminDetailItem.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
@@ -511,11 +512,9 @@ function statusLabel(s: string) {
</template>
<el-table-column prop="id" label="ID" width="72" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" width="88">
<el-table-column :label="t('common.status')" min-width="128">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)" size="small">
{{ statusLabel(row.status) }}
</el-tag>
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
</template>
</el-table-column>
<el-table-column :label="t('user.col.agent')" min-width="120">

View File

@@ -1,5 +1,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
defineOptions({ name: 'AgentBets' });
import { ref } from 'vue';
import { useStaleListLifecycle } from '../../composables/useStaleList';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import { formatAmount } from '../../utils/format-amount';
@@ -22,8 +25,6 @@ const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
onMounted(load);
async function load() {
const { data } = await api.get('/agent/bets', {
params: { page: page.value, pageSize: pageSize.value },
@@ -32,15 +33,17 @@ async function load() {
total.value = data.data.total ?? 0;
}
const { loading, runLoad } = useStaleListLifecycle(() => bets.value.length > 0, load);
function onPageChange(p: number) {
page.value = p;
load();
void runLoad(true);
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load();
void runLoad(true);
}
</script>
@@ -51,7 +54,7 @@ function onSizeChange(size: number) {
<span class="page-desc">{{ t('page.agent_bets.desc') }}</span>
</div>
<el-card class="data-card" shadow="never">
<el-card v-loading="loading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table :data="bets" stripe>
<el-table-column prop="id" :label="t('bet.col.serial')" width="56" align="center" />

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, reactive, h } from 'vue';
defineOptions({ name: 'AgentPlayers' });
import { ref, computed, onMounted, onActivated, watch, reactive, h } from 'vue';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { useAuthStore } from '../../stores/auth';
import api from '../../api';
@@ -288,22 +290,52 @@ const transferAmountCapError = computed(() => {
onMounted(async () => {
await loadProfile();
await loadAgentOptions();
await loadAllPlayers();
if (canManageSubAgents.value) {
const tab = activeViewTab.value;
if (tab === 'players') {
await loadAllPlayers();
} else if (canManageSubAgents.value) {
await reloadSubAgentTabs();
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
}
});
onActivated(() => {
const tab = activeViewTab.value;
if (tab === 'players' && allPlayers.value.length > 0) void loadAllPlayers();
else {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) {
const lvl = Number(m[1]);
const st = subAgentLevelState[lvl];
if (st?.agents.length) loadSubAgentsAtLevel(lvl);
}
}
});
watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
if (m) {
if (!subAgentLevelState[Number(m[1])]?.agents.length) {
if (canManageSubAgents.value && !Object.keys(subAgentLevelState).length) {
void reloadSubAgentTabs().then(() => loadSubAgentsAtLevel(Number(m[1])));
} else {
loadSubAgentsAtLevel(Number(m[1]));
}
}
return;
}
if (tab === 'players' && !allPlayers.value.length) void loadAllPlayers();
});
watch(visibleSubAgentTabLevels, (levels, prev) => {
for (const lvl of levels) {
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) {
loadSubAgentsAtLevel(lvl);
}
watch(visibleSubAgentTabLevels, (levels) => {
const tab = activeViewTab.value;
const m = /^agentLevel-(\d+)$/.exec(tab);
if (!m) return;
const activeLevel = Number(m[1]);
if (levels.includes(activeLevel)) {
const st = subAgentLevelState[activeLevel];
if (!st?.agents.length) loadSubAgentsAtLevel(activeLevel);
}
});

View File

@@ -28,6 +28,7 @@ export interface AdminDashboard {
playersActive: number;
playersSuspended: number;
playersDirect: number;
playersOnlineNow: number;
agentsTotal: number;
agentsActive: number;
};

View File

@@ -30,6 +30,11 @@ const kpiPlayer = computed(() => {
value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`,
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
},
{
label: t('dash.players_online'),
value: fmtCount(s.value.users.playersOnlineNow ?? 0),
sub: t('dash.players_online_hint'),
},
{
label: t('dash.kpi_agents_active'),
value: fmtCount(s.value.users.agentsActive),
@@ -55,6 +60,7 @@ const userDistributionOption = computed(() => {
const userSegs = u
? [
{ label: t('dash.user_active'), value: u.playersActive, color: '#346538' },
{ label: t('dash.user_online'), value: u.playersOnlineNow ?? 0, color: '#2d8a4e' },
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#9f2f2d' },
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#1f6c9f' },
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#956400' },

View File

@@ -81,6 +81,8 @@ export interface AdminMarket {
lineValue: number | null;
paramsJson?: Record<string, unknown> | null;
status: string;
allowSingle?: boolean;
allowParlay?: boolean;
showOnPlayer: boolean;
promoLabel: string;
promoLabelI18n?: Record<string, string>;

View File

@@ -30,7 +30,7 @@ const archiveTitle = ref('');
const matches = ref<unknown[]>([]);
const loading = ref(false);
const matchPage = ref(1);
const matchPageSize = ref(20);
const matchPageSize = ref(10);
const matchTotal = ref(0);
let loadTimer: ReturnType<typeof setTimeout> | null = null;
@@ -328,13 +328,9 @@ defineExpose({ reload: load });
<template>
<div class="league-matches-panel">
<div class="nested-panel-toolbar">
<el-button type="primary" size="small" @click.stop="emit('add-match')">
{{ t('match.create_fixture_btn') }}
</el-button>
</div>
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
<el-table-column prop="id" label="ID" width="64" />
<el-table-column type="index" :index="(i: number) => (matchPage - 1) * matchPageSize + i + 1" :label="t('common.seq')" width="70" align="center" />
<el-table-column :label="t('match.col.matchup')" min-width="180">
<template #default="{ row }">
<span class="matchup-link">{{ matchTitle(row) }}</span>

View File

@@ -198,8 +198,8 @@ function mapMarkets(detail: AdminMatchDetail) {
lineValue: m.lineValue,
paramsJson: m.paramsJson ?? null,
status: m.status,
allowSingle: true,
allowParlay: true,
allowSingle: m.allowSingle ?? true,
allowParlay: m.allowParlay ?? true,
showOnPlayer: m.showOnPlayer ?? true,
sortOrder: m.sortOrder ?? index,
promoLabel: m.promoLabel ?? '',

View File

@@ -72,6 +72,7 @@ export interface PlayerRow {
availableBalance: string;
frozenBalance: string;
lastLoginAt: string | null;
isOnline?: boolean;
betCount: number;
totalStake: string;
totalReturn: string;

View File

@@ -2,6 +2,9 @@ import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import { visualizer } from 'rollup-plugin-visualizer';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig(({ mode }) => {
const analyze = process.env.ANALYZE === '1' || mode === 'analyze';
@@ -9,6 +12,12 @@ export default defineConfig(({ mode }) => {
return {
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
analyze &&
visualizer({
filename: 'dist/stats.html',
@@ -37,7 +46,16 @@ export default defineConfig(({ mode }) => {
const m = id.match(/bundles\/(zh-CN|en-US|ms-MY)/);
if (m) return `i18n-${m[1]}`;
}
if (id.includes('/src/i18n/pages/')) return 'i18n-pages';
// 按语言分别归入各自 chunk避免 zh/en pages 合并进同一个共享 chunk
if (id.includes('/src/i18n/pages/zh')) return 'i18n-zh-CN';
if (id.includes('/src/i18n/pages/en')) return 'i18n-en-US';
if (id.includes('/src/i18n/admin-pages-ms')) return 'i18n-ms-MY';
// admin-pages.ts 如仍被引用,归入共享 chunk当前已无引用保留规则作兜底
if (id.includes('/src/i18n/admin-pages')) return 'i18n-pages';
if (id.includes('/src/views/AgentManager.vue')) return 'admin-users';
if (id.includes('/src/views/Bets.vue')) return 'admin-bets';
if (id.includes('/src/views/Matches.vue')) return 'admin-matches';
if (id.includes('/src/views/MatchesOutrights.vue')) return 'admin-matches-outrights';
if (id.includes('echarts-setup') || id.includes('vue-echarts') || id.includes('node_modules/echarts')) {
return 'echarts';
}

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "visible_menus" VARCHAR(1000);

View File

@@ -0,0 +1,22 @@
-- CreateTable
CREATE TABLE "player_messages" (
"id" BIGSERIAL NOT NULL,
"user_id" BIGINT NOT NULL,
"type" VARCHAR(32) NOT NULL,
"title" VARCHAR(256) NOT NULL,
"body" TEXT NOT NULL,
"payload" JSONB,
"read_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "player_messages_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "player_messages_user_id_created_at_idx" ON "player_messages"("user_id", "created_at" DESC);
-- CreateIndex
CREATE INDEX "player_messages_user_id_read_at_idx" ON "player_messages"("user_id", "read_at");
-- AddForeignKey
ALTER TABLE "player_messages" ADD CONSTRAINT "player_messages_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -23,6 +23,7 @@ model User {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
visibleMenus String? @map("visible_menus") @db.VarChar(1000)
auth UserAuth?
wallet Wallet?
@@ -31,6 +32,7 @@ model User {
bets Bet[]
preferences UserPreference?
depositOrders DepositOrder[] @relation("PlayerDepositOrders")
playerMessages PlayerMessage[]
parent User? @relation("UserHierarchy", fields: [parentId], references: [id])
children User[] @relation("UserHierarchy")
@@ -813,6 +815,23 @@ model DepositOrderAuditLog {
@@map("deposit_order_audit_logs")
}
model PlayerMessage {
id BigInt @id @default(autoincrement())
userId BigInt @map("user_id")
type String @db.VarChar(32)
title String @db.VarChar(256)
body String @db.Text
payload Json?
readAt DateTime? @map("read_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)])
@@index([userId, readAt])
@@map("player_messages")
}
// ============ System Config & Audit ============
model SystemConfig {

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { PresenceService } from '../../domains/presence/presence.service';
import { Decimal } from '@prisma/client/runtime/library';
function dec(v: Decimal | null | undefined) {
@@ -12,7 +13,10 @@ function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
@Injectable()
export class AdminDashboardService {
constructor(private prisma: PrismaService) {}
constructor(
private prisma: PrismaService,
private presence: PresenceService,
) {}
async getOverview() {
const today = new Date();
@@ -60,6 +64,7 @@ export class AdminDashboardService {
walletAgg,
recentBets,
recentPlayers,
playersOnlineNow,
] = await Promise.all([
this.prisma.bet.aggregate({
where: { placedAt: { gte: today } },
@@ -125,6 +130,7 @@ export class AdminDashboardService {
parent: { select: { username: true } },
},
}),
this.presence.getOnlineCount(),
]);
const matchByStatus: Record<string, number> = {};
@@ -164,6 +170,7 @@ export class AdminDashboardService {
playersActive: playerActive,
playersSuspended: playerSuspended,
playersDirect: playerDirect,
playersOnlineNow,
agentsTotal: agentProfiles._count._all,
agentsActive,
},

View File

@@ -50,6 +50,8 @@ import { P } from './admin-permissions';
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
import { PresenceService } from '../../domains/presence/presence.service';
import {
IsString,
IsNumber,
@@ -272,6 +274,10 @@ class CreateStaffDto {
@IsString()
roleCode!: string;
@IsOptional()
@IsString()
visibleMenus?: string;
}
class UpdateStaffDto {
@@ -287,6 +293,10 @@ class UpdateStaffDto {
@IsString()
@MinLength(8)
password?: string;
@IsOptional()
@IsString()
visibleMenus?: string;
}
class ResetPlayerPasswordDto {
@@ -1046,6 +1056,10 @@ class CreateContentDto {
@IsString()
endTime?: string | null;
@IsOptional()
@IsBoolean()
notifyInbox?: boolean;
@IsArray()
translations!: ContentTranslationDto[];
}
@@ -1085,6 +1099,16 @@ class ContentStatusDto {
status!: string;
}
class InboxNotifySettingsDto {
@IsOptional()
@IsBoolean()
inboxEnabled?: boolean;
@IsOptional()
@IsBoolean()
deposit?: boolean;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@@ -1209,9 +1233,18 @@ export class AdminController {
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
private depositService: DepositService,
private playerMessages: PlayerMessagesService,
private staff: AdminStaffService,
private presence: PresenceService,
) {}
@Get('presence/online-count')
@RequirePermissions(P.usersView)
async getOnlinePlayerCount() {
const count = await this.presence.getOnlineCount();
return jsonResponse({ count, asOf: new Date().toISOString() });
}
@Get('dashboard')
@RequirePermissions(P.reports)
async getDashboard() {
@@ -1500,6 +1533,23 @@ export class AdminController {
return jsonResponse(updated);
}
@Delete('staff/:id')
@RequirePermissions(P.settings)
async deleteStaff(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
await this.staff.deleteStaff(BigInt(id), operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'DELETE_STAFF',
module: 'STAFF',
targetId: id,
});
return jsonResponse({ deleted: true });
}
@Delete('users/:id')
@RequirePermissions(P.usersCreate)
async deletePlayer(
@@ -3104,6 +3154,20 @@ export class AdminController {
return urls;
}
@Get('contents/inbox-notify-settings')
@RequirePermissions(P.content, P.reports)
async getInboxNotifySettings() {
const settings = await this.systemConfig.getInboxNotifySettings();
return jsonResponse(settings);
}
@Put('contents/inbox-notify-settings')
@RequirePermissions(P.content)
async updateInboxNotifySettings(@Body() dto: InboxNotifySettingsDto) {
const settings = await this.systemConfig.updateInboxNotifySettings(dto);
return jsonResponse(settings);
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(
@@ -3128,8 +3192,34 @@ export class AdminController {
@Post('contents')
@RequirePermissions(P.content)
async createContent(@Body() dto: CreateContentDto) {
const item = await this.content.create(dto);
return jsonResponse(item);
const { notifyInbox, ...createDto } = dto;
const item = await this.content.create(createDto);
let notifiedCount: number | undefined;
if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled();
if (inboxEnabled) {
const translations = createDto.translations.map((tr) => ({
locale: tr.locale,
title: tr.title,
body: tr.body,
}));
if (createDto.contentType === 'BANNER') {
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
contentId: BigInt(item.id),
translations,
});
} else if (
createDto.contentType === 'NOTICE' ||
createDto.contentType === 'TICKER'
) {
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
contentId: BigInt(item.id),
translations,
});
}
}
}
return jsonResponse({ ...item, notifiedCount });
}
@Put('contents/:id')
@@ -3349,6 +3439,13 @@ export class AdminController {
return jsonResponse(result);
}
@Get('deposit-orders/pending-count')
@RequirePermissions(P.depositReview)
async depositPendingCount() {
const count = await this.depositService.countPendingDepositOrders();
return jsonResponse({ count });
}
@Get('deposit-orders/:id/audit-logs')
@RequirePermissions(P.depositReview)
async depositOrderAuditLogs(@Param('id') id: string) {

View File

@@ -15,6 +15,8 @@ import { BetsModule } from '../../domains/betting/bets.module';
import { DatabaseModule } from '../../infrastructure/database/database.module';
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
import { DepositModule } from '../../domains/deposit/deposit.module';
import { PlayerMessagesModule } from '../../domains/player-messages/player-messages.module';
import { PresenceModule } from '../../domains/presence/presence.module';
@Module({
imports: [
@@ -31,6 +33,8 @@ import { DepositModule } from '../../domains/deposit/deposit.module';
DatabaseModule,
SmokeTestModule,
DepositModule,
PlayerMessagesModule,
PresenceModule,
],
controllers: [AdminController],
providers: [AdminDashboardService, PermissionsGuard],

View File

@@ -3,6 +3,7 @@ import {
Get,
Post,
Patch,
Delete,
Body,
Param,
Query,
@@ -30,6 +31,8 @@ import { BetsService } from '../../domains/betting/bets.service';
import { ContentService } from '../../domains/operations/content/content.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
import { DepositService } from '../../domains/deposit/deposit.service';
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
import { PresenceService } from '../../domains/presence/presence.service';
import { isInLocalTodayMatchWindow } from '@thebet365/shared';
import { IsString, IsNumber, IsArray, ValidateNested, Min, IsOptional } from 'class-validator';
import { Type } from 'class-transformer';
@@ -120,8 +123,16 @@ export class PlayerController {
private cashback: CashbackService,
private systemConfig: SystemConfigService,
private deposit: DepositService,
private playerMessages: PlayerMessagesService,
private presence: PresenceService,
) {}
@Post('presence/ping')
async presencePing(@CurrentUser('id') userId: bigint) {
await this.presence.touch(userId);
return jsonResponse({ ok: true });
}
private async formatPlayerProfile(user: NonNullable<Awaited<ReturnType<UsersService['findById']>>>) {
const accountSettings = await this.systemConfig.getPlayerAccountSettings();
const prefs = user.preferences;
@@ -175,10 +186,12 @@ export class PlayerController {
@Headers('x-time-zone') headerTimeZone?: string,
) {
const locale = userLocale || headerLocale || 'zh-CN';
const [banners, announcements, allMatches] = await Promise.all([
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.systemConfig.getInboxFeatureEnabled(),
]);
const timeZone = safeTimeZone(headerTimeZone);
const now = new Date();
@@ -198,6 +211,8 @@ export class PlayerController {
notices: announcements,
hotMatches,
todayMatches,
upcomingMatches,
inboxEnabled,
});
}
@@ -245,6 +260,20 @@ export class PlayerController {
return jsonResponse(match);
}
@Public()
@Get('selections/odds')
async selectionOdds(@Query('ids') ids?: string) {
if (!ids?.trim()) throw appBadRequest('SELECTION_NOT_FOUND');
const parsed = ids
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => BigInt(s));
if (!parsed.length || parsed.length > 20) throw appBadRequest('PARLAY_LEG_COUNT_INVALID');
const items = await this.matches.getSelectionsOdds(parsed);
return jsonResponse({ items });
}
@Post('bets/single')
async singleBet(@CurrentUser('id') userId: bigint, @CurrentUser('parentId') parentId: bigint, @Body() dto: SingleBetDto) {
const bet = await this.bets.placeSingleBet(
@@ -449,4 +478,56 @@ export class PlayerController {
createdAt: order!.createdAt,
});
}
// ============ Messages / Inbox ============
@Get('messages')
async listMessages(
@CurrentUser('id') userId: bigint,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const result = await this.playerMessages.listForPlayer(
userId,
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 20,
);
return jsonResponse(result);
}
@Get('messages/unread-count')
async messageUnreadCount(@CurrentUser('id') userId: bigint) {
const result = await this.playerMessages.getUnreadCount(userId);
return jsonResponse(result);
}
@Get('messages/:id')
async messageDetail(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
const message = await this.playerMessages.getForPlayer(userId, BigInt(id));
return jsonResponse(message);
}
@Patch('messages/read-all')
async markAllMessagesRead(@CurrentUser('id') userId: bigint) {
const result = await this.playerMessages.markAllRead(userId);
return jsonResponse(result);
}
@Patch('messages/:id/read')
async markMessageRead(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
const message = await this.playerMessages.markRead(userId, BigInt(id));
return jsonResponse(message);
}
@Delete('messages')
async deleteAllMessages(@CurrentUser('id') userId: bigint) {
const result = await this.playerMessages.deleteAllForPlayer(userId);
return jsonResponse(result);
}
@Delete('messages/:id')
async deleteMessage(@CurrentUser('id') userId: bigint, @Param('id') id: string) {
const result = await this.playerMessages.deleteForPlayer(userId, BigInt(id));
return jsonResponse(result);
}
}

View File

@@ -7,9 +7,11 @@ import { BetsModule } from '../../domains/betting/bets.module';
import { ContentModule } from '../../domains/operations/content/content.module';
import { CashbackModule } from '../../domains/operations/cashback/cashback.module';
import { DepositModule } from '../../domains/deposit/deposit.module';
import { PlayerMessagesModule } from '../../domains/player-messages/player-messages.module';
import { PresenceModule } from '../../domains/presence/presence.module';
@Module({
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule],
imports: [UsersModule, WalletModule, MatchesModule, BetsModule, ContentModule, CashbackModule, DepositModule, PlayerMessagesModule, PresenceModule],
controllers: [PlayerController],
})
export class PlayerModule {}

View File

@@ -1,10 +1,11 @@
jest.mock('@thebet365/shared', () => ({
isPreMatchKickoff: jest.fn(() => true),
PARLAY_MARKET_TYPES: [],
resolveTranslationFallback: jest.fn(
(translations: Map<string, string>, locale: string) =>
translations.get(locale) ?? translations.get('zh-CN') ?? translations.get('en-US') ?? null,
),
resolveTranslationFallback: jest.fn((translations: Map<string, string> | Record<string, string>, locale: string) => {
const get = (key: string) =>
translations instanceof Map ? translations.get(key) : translations[key];
return get(locale) ?? get('zh-CN') ?? get('en-US') ?? null;
}),
}));
import { MatchesService } from './matches.service';
@@ -18,6 +19,7 @@ describe('MatchesService publish/unpublish', () => {
match: { findFirst: jest.Mock; update: jest.Mock };
entityTranslation: { findFirst: jest.Mock; upsert: jest.Mock };
settlementBatch: { deleteMany: jest.Mock };
marketSelection: { findMany: jest.Mock };
};
let outright: { syncWithLeaguePublished: jest.Mock };
let matchBetStats: { betStatsForMatches: jest.Mock };
@@ -36,6 +38,7 @@ describe('MatchesService publish/unpublish', () => {
},
entityTranslation: { findFirst: jest.fn().mockResolvedValue(null), upsert: jest.fn().mockResolvedValue({}) },
settlementBatch: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
marketSelection: { findMany: jest.fn().mockResolvedValue([]) },
};
outright = { syncWithLeaguePublished: jest.fn().mockResolvedValue(undefined) };
matchBetStats = { betStatsForMatches: jest.fn().mockResolvedValue(new Map()) };
@@ -124,3 +127,177 @@ describe('MatchesService publish/unpublish', () => {
});
});
});
describe('MatchesService getSelectionsOdds', () => {
const selectionId = BigInt(100);
const matchId = BigInt(10);
let prisma: { marketSelection: { findMany: jest.Mock } };
let service: MatchesService;
beforeEach(() => {
prisma = {
marketSelection: { findMany: jest.fn() },
};
service = new MatchesService(
prisma as never,
{ syncWithLeaguePublished: jest.fn() } as never,
{ betStatsForMatches: jest.fn() } as never,
);
});
it('returns odds snapshot for requested selections', async () => {
prisma.marketSelection.findMany.mockResolvedValue([
{
id: selectionId,
odds: { toString: () => '1.95' },
oddsVersion: BigInt(3),
status: 'OPEN',
market: {
status: 'OPEN',
showOnPlayer: true,
match: { id: matchId, status: 'PUBLISHED' },
},
},
]);
const result = await service.getSelectionsOdds([selectionId]);
expect(prisma.marketSelection.findMany).toHaveBeenCalledWith({
where: { id: { in: [selectionId] } },
include: { market: { include: { match: true } } },
});
expect(result).toEqual([
{
id: '100',
odds: '1.95',
oddsVersion: '3',
status: 'OPEN',
marketStatus: 'OPEN',
marketShowOnPlayer: true,
matchStatus: 'PUBLISHED',
matchId: '10',
},
]);
});
it('returns empty array when ids not found', async () => {
prisma.marketSelection.findMany.mockResolvedValue([]);
const result = await service.getSelectionsOdds([BigInt(999)]);
expect(result).toEqual([]);
});
it('returns empty array for empty id list', async () => {
prisma.marketSelection.findMany.mockResolvedValue([]);
const result = await service.getSelectionsOdds([]);
expect(prisma.marketSelection.findMany).toHaveBeenCalledWith({
where: { id: { in: [] } },
include: { market: { include: { match: true } } },
});
expect(result).toEqual([]);
});
});
describe('MatchesService listUpcomingPublished', () => {
const leagueId = BigInt(1);
const homeTeamId = BigInt(2);
const awayTeamId = BigInt(3);
const matchId = BigInt(10);
let prisma: {
match: { findMany: jest.Mock };
entityTranslation: { findMany: jest.Mock };
};
let service: MatchesService;
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-06-17T12:00:00.000Z'));
prisma = {
match: { findMany: jest.fn() },
entityTranslation: {
findMany: jest.fn().mockResolvedValue([{ locale: 'zh-CN', fieldName: 'name', value: '测试' }]),
},
};
service = new MatchesService(
prisma as never,
{ syncWithLeaguePublished: jest.fn() } as never,
{ betStatsForMatches: jest.fn().mockResolvedValue(new Map()) } as never,
);
});
afterEach(() => {
jest.useRealTimers();
});
it('queries published matches within the next 3 days sorted by startTime', async () => {
const now = new Date('2026-06-17T12:00:00.000Z');
const end = new Date(now);
end.setDate(end.getDate() + 3);
prisma.match.findMany.mockResolvedValue([
{
id: matchId,
leagueId,
homeTeamId,
awayTeamId,
startTime: new Date('2026-06-18T15:00:00.000Z'),
status: 'PUBLISHED',
isHot: false,
displayOrder: 0,
matchName: null,
stage: null,
groupName: null,
league: { logoUrl: null },
homeTeam: { code: 'HME', logoUrl: null },
awayTeam: { code: 'AWY', logoUrl: null },
score: null,
markets: [],
},
]);
const result = await service.listUpcomingPublished('zh-CN');
expect(prisma.match.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
isOutright: false,
sportType: 'FOOTBALL',
deletedAt: null,
startTime: { gte: now, lte: end },
}),
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
take: 50,
}),
);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(
expect.objectContaining({
id: '10',
startTime: '2026-06-18T15:00:00.000Z',
isHot: false,
}),
);
});
it('respects custom limit and days options', async () => {
prisma.match.findMany.mockResolvedValue([]);
await service.listUpcomingPublished('en-US', { limit: 20, days: 5 });
const now = new Date('2026-06-17T12:00:00.000Z');
const end = new Date(now);
end.setDate(end.getDate() + 5);
expect(prisma.match.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
startTime: { gte: now, lte: end },
}),
take: 20,
}),
);
});
});

View File

@@ -871,6 +871,8 @@ export class MatchesService {
lineValue: m.lineValue != null ? Number(m.lineValue) : null,
paramsJson: m.paramsJson ?? null,
status: m.status,
allowSingle: m.allowSingle,
allowParlay: m.allowParlay,
showOnPlayer: m.showOnPlayer,
promoLabel: m.promoLabel ?? '',
promoLabelI18n: sanitizeLocalizedText(m.promoLabelI18n),
@@ -1459,6 +1461,42 @@ export class MatchesService {
);
}
/** 未来 N 天内开赛的已发布赛事(按开赛时间升序,不限 isHot */
async listUpcomingPublished(
locale = 'en-US',
options?: { limit?: number; days?: number },
) {
const limit = options?.limit ?? 50;
const days = options?.days ?? 3;
const now = new Date();
const end = new Date(now);
end.setDate(end.getDate() + days);
const matches = await this.prisma.match.findMany({
where: {
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
isOutright: false,
sportType: 'FOOTBALL',
deletedAt: null,
startTime: { gte: now, lte: end },
league: { isActive: true, deletedAt: null },
},
include: {
league: true,
homeTeam: true,
awayTeam: true,
score: true,
markets: this.playerMarketStatusInclude,
},
orderBy: [{ startTime: 'asc' }, { displayOrder: 'asc' }],
take: limit,
});
return Promise.all(
matches.map((m) => this.enrichMatch(m, locale, { omitMarkets: true })),
);
}
async getMatchDetail(matchId: bigint, locale = 'en-US') {
const match = await this.prisma.match.findFirst({
where: {
@@ -1481,6 +1519,24 @@ export class MatchesService {
return this.enrichMatch(match, locale);
}
async getSelectionsOdds(ids: bigint[]) {
const selections = await this.prisma.marketSelection.findMany({
where: { id: { in: ids } },
include: { market: { include: { match: true } } },
});
return selections.map((sel) => ({
id: sel.id.toString(),
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
status: sel.status,
marketStatus: sel.market.status,
marketShowOnPlayer: sel.market.showOnPlayer,
matchStatus: sel.market.match.status,
matchId: sel.market.match.id.toString(),
}));
}
async listOutrights(locale = 'en-US') {
try {
await syncWc2026OutrightMarket(this.prisma, { forceCanonical: false });

View File

@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { DepositService } from './deposit.service';
import { WalletModule } from '../ledger/wallet.module';
import { AgentsModule } from '../agent/agents.module';
import { PlayerMessagesModule } from '../player-messages/player-messages.module';
import { SystemConfigModule } from '../../shared/config/system-config.module';
@Module({
imports: [WalletModule, AgentsModule],
imports: [WalletModule, AgentsModule, PlayerMessagesModule, SystemConfigModule],
providers: [DepositService],
exports: [DepositService],
})

View File

@@ -19,6 +19,7 @@ describe('DepositService', () => {
},
user: {
findFirst: jest.fn(),
findUnique: jest.fn(),
},
agentProfile: {
findUnique: jest.fn(),
@@ -38,12 +39,28 @@ describe('DepositService', () => {
const credit = {
recalculateUsedCredit: jest.fn(),
};
const playerMessages = {
createDepositApprovedMessage: jest.fn(),
createDepositRejectedMessage: jest.fn(),
};
const systemConfig = {
getInboxNotifySettings: jest.fn().mockResolvedValue({
inboxEnabled: true,
deposit: true,
}),
};
let service: DepositService;
beforeEach(() => {
jest.clearAllMocks();
service = new DepositService(prisma as never, funds as never, credit as never);
service = new DepositService(
prisma as never,
funds as never,
credit as never,
playerMessages as never,
systemConfig as never,
);
tx.$queryRaw.mockResolvedValue([{ id: 1n }]);
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
@@ -57,8 +74,11 @@ describe('DepositService', () => {
});
tx.bet.findMany.mockResolvedValue([]);
tx.user.findFirst.mockResolvedValue(null);
tx.user.findUnique.mockResolvedValue({ locale: 'en-US' });
tx.agentProfile.findUnique.mockResolvedValue(null);
credit.recalculateUsedCredit.mockResolvedValue(undefined);
playerMessages.createDepositApprovedMessage.mockResolvedValue({});
playerMessages.createDepositRejectedMessage.mockResolvedValue({});
});
it('posts approved deposits as player wallet transactions and refreshes parent credit', async () => {
@@ -131,7 +151,7 @@ describe('DepositService', () => {
});
it('uses approval cycle key when revoking a funded deposit for re-review', async () => {
const reviewedAt = new Date('2026-06-15T14:08:48.000Z');
const reviewedAt = new Date();
tx.depositOrder.findUnique.mockResolvedValue({
id: 1n,
orderNo: 'DEP-1',

View File

@@ -7,6 +7,8 @@ import { FundsPostingService } from '../ledger/funds-posting.service';
import { AgentCreditService } from '../agent/agent-credit.service';
import { appBadRequest } from '../../shared/common/app-error';
import { deleteUploadFileByUrl } from '../../shared/uploads/delete-upload-file';
import { PlayerMessagesService } from '../player-messages/player-messages.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
function generateOrderNo(): string {
const ts = Date.now().toString(36).toUpperCase();
@@ -49,6 +51,8 @@ export class DepositService {
private prisma: PrismaService,
private funds: FundsPostingService,
private credit: AgentCreditService,
private playerMessages: PlayerMessagesService,
private systemConfig: SystemConfigService,
) {}
// ============ Payment Methods (Admin CRUD) ============
@@ -254,6 +258,14 @@ export class DepositService {
// ============ Deposit Orders ============
private async getPlayerLocale(playerId: bigint, tx: Prisma.TransactionClient | PrismaService = this.prisma) {
const user = await tx.user.findUnique({
where: { id: playerId },
select: { locale: true },
});
return user?.locale ?? 'en-US';
}
private async recordDepositAudit(
client: AuditLogWriter,
data: {
@@ -670,6 +682,10 @@ export class DepositService {
};
}
async countPendingDepositOrders(): Promise<number> {
return this.prisma.depositOrder.count({ where: { status: 'PENDING' } });
}
async approveDepositOrder(
orderId: bigint,
operatorId: bigint,
@@ -722,6 +738,22 @@ export class DepositService {
await this.credit.recalculateUsedCredit(parentAgentId, tx);
}
const playerLocale = await this.getPlayerLocale(order.playerId, tx);
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
await this.playerMessages.createDepositApprovedMessage(
order.playerId,
{
depositOrderId: orderId,
orderNo: order.orderNo,
amount: order.amount.toString(),
approvedAmount: creditAmount.toString(),
locale: playerLocale,
},
tx,
);
}
return { success: true };
});
}
@@ -753,6 +785,18 @@ export class DepositService {
remark: reason,
});
const playerLocale = await this.getPlayerLocale(order.playerId);
const inboxNotify = await this.systemConfig.getInboxNotifySettings();
if (inboxNotify.inboxEnabled && inboxNotify.deposit) {
await this.playerMessages.createDepositRejectedMessage(order.playerId, {
depositOrderId: orderId,
orderNo: order.orderNo,
amount: order.amount.toString(),
rejectReason: reason,
locale: playerLocale,
});
}
return { success: true };
}

View File

@@ -61,11 +61,12 @@ export class AdminStaffService {
roleName: u.adminRole?.role?.name ?? null,
lastLoginAt: u.auth?.lastLoginAt ?? null,
createdAt: u.createdAt,
visibleMenus: u.visibleMenus,
})),
};
}
async createStaff(data: { username: string; password: string; roleCode: string }) {
async createStaff(data: { username: string; password: string; roleCode: string; visibleMenus?: string }) {
const username = data.username.trim();
if (!username) throw appBadRequest('USERNAME_REQUIRED');
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
@@ -86,6 +87,7 @@ export class AdminStaffService {
userType: 'ADMIN',
auth: { create: { passwordHash: hash } },
adminRole: { create: { roleId: role.id } },
visibleMenus: data.visibleMenus,
},
include: {
adminRole: { include: { role: { select: { code: true, name: true } } } },
@@ -99,12 +101,13 @@ export class AdminStaffService {
status: user.status,
role: user.adminRole?.role?.code ?? null,
roleName: user.adminRole?.role?.name ?? null,
visibleMenus: user.visibleMenus,
};
}
async updateStaff(
staffId: bigint,
data: { status?: string; roleCode?: string; password?: string },
data: { status?: string; roleCode?: string; password?: string; visibleMenus?: string },
) {
const user = await this.prisma.user.findFirst({
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
@@ -140,6 +143,13 @@ export class AdminStaffService {
}
}
if (data.visibleMenus !== undefined) {
await this.prisma.user.update({
where: { id: staffId },
data: { visibleMenus: data.visibleMenus },
});
}
let plainPassword: string | undefined;
if (data.password !== undefined) {
if (data.password.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
@@ -163,10 +173,40 @@ export class AdminStaffService {
status: refreshed!.status,
role: refreshed!.adminRole?.role?.code ?? null,
roleName: refreshed!.adminRole?.role?.name ?? null,
visibleMenus: refreshed!.visibleMenus,
...(plainPassword ? { password: plainPassword } : {}),
};
}
async deleteStaff(staffId: bigint, operatorId?: bigint) {
if (operatorId && staffId === operatorId) {
throw appBadRequest('CANNOT_DELETE_SELF');
}
const user = await this.prisma.user.findFirst({
where: { id: staffId, userType: 'ADMIN', deletedAt: null },
include: { adminRole: { include: { role: true } } },
});
if (!user) throw appNotFound('STAFF_NOT_FOUND');
if (user.adminRole?.role?.code === 'SUPER_ADMIN') {
const superAdminCount = await this.prisma.user.count({
where: {
userType: 'ADMIN',
deletedAt: null,
adminRole: { role: { code: 'SUPER_ADMIN' } },
},
});
if (superAdminCount <= 1) {
throw appBadRequest('CANNOT_DELETE_LAST_SUPER_ADMIN');
}
}
return this.prisma.user.update({
where: { id: staffId },
data: { deletedAt: new Date(), status: 'DISABLED' },
});
}
async resetPlayerPassword(playerId: bigint, password?: string) {
const user = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },

View File

@@ -10,6 +10,8 @@ import { JwtAuthGuard } from './guards';
import { jsonResponse } from '../../shared/common/filters';
import { getClientIp } from '../../shared/common/client-ip.util';
import { PrismaService } from '../../shared/prisma/prisma.service';
@ApiTags('Auth')
@Controller()
export class AuthController {
@@ -17,6 +19,7 @@ export class AuthController {
private auth: AuthService,
private invites: InvitesService,
private systemConfig: SystemConfigService,
private prisma: PrismaService,
) {}
@Public()
@@ -189,6 +192,15 @@ export class AuthController {
inviteCode = (await this.auth.getInviteInfo(userId)).inviteCode;
}
let visibleMenus: string | null = null;
if (userType === 'ADMIN') {
const userDb = await this.prisma.user.findUnique({
where: { id: userId },
select: { visibleMenus: true },
});
visibleMenus = userDb?.visibleMenus ?? null;
}
return jsonResponse({
id: userId.toString(),
username,
@@ -200,6 +212,7 @@ export class AuthController {
maxAgentLevel,
canManageSubAgents,
inviteCode,
visibleMenus,
});
}

View File

@@ -207,6 +207,7 @@ export class AuthService {
locale: user.locale,
role: user.adminRole?.role?.code,
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
visibleMenus: user.visibleMenus,
...(adminPermissions ? { permissions: adminPermissions } : {}),
},
};

View File

@@ -3,9 +3,10 @@ import { UsersService } from './users.service';
import { AdminStaffService } from './admin-staff.service';
import { AgentsModule } from '../agent/agents.module';
import { CashbackModule } from '../operations/cashback/cashback.module';
import { PresenceModule } from '../presence/presence.module';
@Module({
imports: [AgentsModule, CashbackModule],
imports: [AgentsModule, CashbackModule, PresenceModule],
providers: [UsersService, AdminStaffService],
exports: [UsersService, AdminStaffService],
})

View File

@@ -5,6 +5,7 @@ import { PrismaService } from '../../shared/prisma/prisma.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { AgentsService } from '../agent/agents.service';
import { CashbackService } from '../operations/cashback/cashback.service';
import { PresenceService } from '../presence/presence.service';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
import { Decimal } from '@prisma/client/runtime/library';
@@ -22,6 +23,7 @@ export class UsersService {
private agents: AgentsService,
private systemConfig: SystemConfigService,
private cashback: CashbackService,
private presence: PresenceService,
) {}
private buildAffiliationAgents(
@@ -297,14 +299,19 @@ export class UsersService {
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
const affiliationMap = await this.buildAffiliationChainMap(rows.map((r) => r.parentId));
const onlineSet = await this.presence.filterOnlineIds(rows.map((r) => r.id));
return {
items: rows.map((u) =>
this.formatPlayerRow(
items: rows.map((u) => {
const row = this.formatPlayerRow(
u,
betMap.get(u.id.toString()),
u.parentId ? affiliationMap.get(u.parentId.toString()) : undefined,
),
),
);
return {
...row,
isOnline: onlineSet.has(row.id),
};
}),
total,
page,
pageSize,

View File

@@ -222,6 +222,9 @@ export class ContentService {
id: item.id.toString(),
contentType: item.contentType,
sortOrder: item.sortOrder,
createdAt: item.createdAt.toISOString(),
linkType: item.linkType,
linkTarget: item.linkTarget,
translation: tr,
};
});
@@ -252,6 +255,7 @@ export class ContentService {
id: item.id.toString(),
contentType: item.contentType,
sortOrder: item.sortOrder,
createdAt: item.createdAt.toISOString(),
linkType: item.linkType,
linkTarget: item.linkTarget,
translation: t,

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PlayerMessagesService } from './player-messages.service';
@Module({
providers: [PlayerMessagesService],
exports: [PlayerMessagesService],
})
export class PlayerMessagesModule {}

View File

@@ -0,0 +1,165 @@
import { PlayerMessagesService } from './player-messages.service';
describe('PlayerMessagesService', () => {
const prisma = {
playerMessage: {
create: jest.fn(),
findMany: jest.fn(),
count: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
delete: jest.fn(),
deleteMany: jest.fn(),
createMany: jest.fn(),
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
};
let service: PlayerMessagesService;
beforeEach(() => {
jest.clearAllMocks();
service = new PlayerMessagesService(prisma as never);
});
it('creates localized deposit approved messages', async () => {
prisma.playerMessage.create.mockResolvedValue({
id: 1n,
type: 'DEPOSIT_APPROVED',
title: '充值已到账',
body: 'body',
payload: { orderNo: 'DEP-1' },
readAt: null,
createdAt: new Date('2026-06-17T10:00:00.000Z'),
});
const result = await service.createDepositApprovedMessage(7n, {
depositOrderId: 10n,
orderNo: 'DEP-1',
amount: '100',
approvedAmount: '100',
locale: 'zh-CN',
});
expect(prisma.playerMessage.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: 7n,
type: 'DEPOSIT_APPROVED',
title: '充值已到账',
}),
}),
);
expect(result.type).toBe('DEPOSIT_APPROVED');
expect(result.isRead).toBe(false);
});
it('returns paginated inbox with unread count', async () => {
prisma.playerMessage.findMany.mockResolvedValue([
{
id: 2n,
type: 'DEPOSIT_REJECTED',
title: 'Deposit rejected',
body: 'Rejected',
payload: null,
readAt: null,
createdAt: new Date('2026-06-17T11:00:00.000Z'),
},
]);
prisma.playerMessage.count.mockResolvedValueOnce(1).mockResolvedValueOnce(1);
const result = await service.listForPlayer(7n, 1, 20);
expect(result.items).toHaveLength(1);
expect(result.unreadCount).toBe(1);
expect(result.total).toBe(1);
});
it('deletes a single message for the player', async () => {
prisma.playerMessage.findFirst.mockResolvedValue({
id: 3n,
type: 'DEPOSIT_APPROVED',
title: 'Deposit approved',
body: 'body',
payload: null,
readAt: null,
createdAt: new Date('2026-06-17T12:00:00.000Z'),
});
prisma.playerMessage.delete.mockResolvedValue({ id: 3n });
const result = await service.deleteForPlayer(7n, 3n);
expect(prisma.playerMessage.delete).toHaveBeenCalledWith({ where: { id: 3n } });
expect(result).toEqual({ deleted: true, wasUnread: true });
});
it('deletes all messages for the player', async () => {
prisma.playerMessage.deleteMany.mockResolvedValue({ count: 4 });
const result = await service.deleteAllForPlayer(7n);
expect(prisma.playerMessage.deleteMany).toHaveBeenCalledWith({ where: { userId: 7n } });
expect(result).toEqual({ deleted: 4 });
});
it('broadcasts banner promotion inbox messages to active players', async () => {
prisma.user.findMany.mockResolvedValue([
{ id: 10n, locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
{ id: 11n, locale: 'en-US', preferences: null },
]);
prisma.playerMessage.createMany.mockResolvedValue({ count: 2 });
const count = await service.broadcastBannerPromotion({
contentId: 99n,
translations: [
{ locale: 'zh-CN', title: '夏季活动', body: '<p>限时优惠</p>' },
{ locale: 'en-US', title: 'Summer promo', body: '' },
],
});
expect(count).toBe(2);
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([
expect.objectContaining({
userId: 10n,
type: 'BANNER_PROMO',
title: '夏季活动',
body: '限时优惠',
}),
expect.objectContaining({
userId: 11n,
type: 'BANNER_PROMO',
title: 'Summer promo',
}),
]),
});
});
it('broadcasts announcement promotion inbox messages to active players', async () => {
prisma.user.findMany.mockResolvedValue([
{ id: 10n, locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
]);
prisma.playerMessage.createMany.mockResolvedValue({ count: 1 });
const count = await service.broadcastAnnouncementPromotion({
contentId: 100n,
translations: [{ locale: 'zh-CN', title: '维护通知', body: '系统维护中' }],
});
expect(count).toBe(1);
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
userId: 10n,
type: 'ANNOUNCEMENT_PROMO',
title: '维护通知',
body: '系统维护中',
}),
],
});
});
});

View File

@@ -0,0 +1,437 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appNotFound } from '../../shared/common/app-error';
export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO';
export type DepositMessagePayload = {
depositOrderId: string;
orderNo: string;
amount: string;
approvedAmount?: string | null;
rejectReason?: string | null;
};
export type BannerPromoPayload = {
contentId: string;
};
type ContentTranslationLike = {
locale: string;
title?: string | null;
body?: string | null;
};
const SUPPORTED_LOCALES = ['zh-CN', 'en-US', 'ms-MY'] as const;
const BANNER_PROMO_DEFAULT_TITLE: Record<string, string> = {
'zh-CN': '新推广活动',
'en-US': 'New promotion',
'ms-MY': 'Promosi baharu',
};
const ANNOUNCEMENT_PROMO_DEFAULT_TITLE: Record<string, string> = {
'zh-CN': '新公告',
'en-US': 'New announcement',
'ms-MY': 'Pengumuman baharu',
};
type MessageTemplate = {
title: string;
body: (payload: DepositMessagePayload) => string;
};
const MESSAGE_TEMPLATES: Record<
Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
Record<string, MessageTemplate>
> = {
DEPOSIT_APPROVED: {
'zh-CN': {
title: '充值已到账',
body: (p) =>
`您的充值订单 ${p.orderNo} 已审核通过,申请金额 ${p.amount},到账金额 ${p.approvedAmount ?? p.amount}`,
},
'en-US': {
title: 'Deposit approved',
body: (p) =>
`Your deposit order ${p.orderNo} has been approved. Requested ${p.amount}, credited ${p.approvedAmount ?? p.amount}.`,
},
'ms-MY': {
title: 'Deposit diluluskan',
body: (p) =>
`Pesanan deposit ${p.orderNo} telah diluluskan. Diminta ${p.amount}, dikreditkan ${p.approvedAmount ?? p.amount}.`,
},
},
DEPOSIT_REJECTED: {
'zh-CN': {
title: '充值未通过',
body: (p) => {
const reason = p.rejectReason?.trim();
return reason
? `您的充值订单 ${p.orderNo}${p.amount})未通过审核。原因:${reason}`
: `您的充值订单 ${p.orderNo}${p.amount})未通过审核。`;
},
},
'en-US': {
title: 'Deposit rejected',
body: (p) => {
const reason = p.rejectReason?.trim();
return reason
? `Your deposit order ${p.orderNo} (${p.amount}) was rejected. Reason: ${reason}`
: `Your deposit order ${p.orderNo} (${p.amount}) was rejected.`;
},
},
'ms-MY': {
title: 'Deposit ditolak',
body: (p) => {
const reason = p.rejectReason?.trim();
return reason
? `Pesanan deposit ${p.orderNo} (${p.amount}) ditolak. Sebab: ${reason}`
: `Pesanan deposit ${p.orderNo} (${p.amount}) ditolak.`;
},
},
},
};
function resolveLocale(locale?: string | null): string {
const value = locale?.trim();
if (value && SUPPORTED_LOCALES.includes(value as (typeof SUPPORTED_LOCALES)[number])) {
return value;
}
return 'en-US';
}
function pickContentTranslation<T extends { locale: string }>(
translations: T[],
locale: string,
): T | undefined {
const chain = [locale, 'en-US', 'zh-CN', 'ms-MY'];
for (const loc of chain) {
const hit = translations.find((tr) => tr.locale === loc);
if (hit) return hit;
}
return translations[0];
}
function stripHtml(value: string): string {
return value.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
function buildBannerPromoFallbackBody(locale: string, title: string): string {
if (locale === 'zh-CN') return `${title}」已上线,请到首页查看详情。`;
if (locale === 'ms-MY') return `"${title}" kini tersedia. Lihat butiran di halaman utama.`;
return `"${title}" is now live. View details on the home page.`;
}
function buildAnnouncementPromoFallbackBody(locale: string, title: string): string {
if (locale === 'zh-CN') return `公告「${title}」已发布,请及时查看。`;
if (locale === 'ms-MY') return `Pengumuman "${title}" telah diterbitkan. Sila semak.`;
return `Announcement "${title}" is published. Please check it out.`;
}
function buildBannerPromoMessage(
locale: string | null | undefined,
contentId: bigint,
translations: ContentTranslationLike[],
) {
const resolvedLocale = resolveLocale(locale);
const tr = pickContentTranslation(translations, resolvedLocale);
const title =
tr?.title?.trim() ||
BANNER_PROMO_DEFAULT_TITLE[resolvedLocale] ||
BANNER_PROMO_DEFAULT_TITLE['en-US'];
const rawBody = tr?.body?.trim() ? stripHtml(tr.body) : '';
const body = rawBody || buildBannerPromoFallbackBody(resolvedLocale, title);
const payload: BannerPromoPayload = { contentId: contentId.toString() };
return {
type: 'BANNER_PROMO' as const,
title,
body,
payload,
};
}
function buildAnnouncementPromoMessage(
locale: string | null | undefined,
contentId: bigint,
translations: ContentTranslationLike[],
) {
const resolvedLocale = resolveLocale(locale);
const tr = pickContentTranslation(translations, resolvedLocale);
const title =
tr?.title?.trim() ||
(tr?.body?.trim() ? tr.body.trim().slice(0, 40) : '') ||
ANNOUNCEMENT_PROMO_DEFAULT_TITLE[resolvedLocale] ||
ANNOUNCEMENT_PROMO_DEFAULT_TITLE['en-US'];
const rawBody = tr?.body?.trim() ?? '';
const body = rawBody || buildAnnouncementPromoFallbackBody(resolvedLocale, title);
const payload: BannerPromoPayload = { contentId: contentId.toString() };
return {
type: 'ANNOUNCEMENT_PROMO' as const,
title,
body,
payload,
};
}
function mapMessageRow(row: {
id: bigint;
type: string;
title: string;
body: string;
payload: Prisma.JsonValue;
readAt: Date | null;
createdAt: Date;
}) {
return {
id: row.id.toString(),
type: row.type,
title: row.title,
body: row.body,
payload: row.payload ?? null,
readAt: row.readAt?.toISOString() ?? null,
createdAt: row.createdAt.toISOString(),
isRead: row.readAt != null,
};
}
@Injectable()
export class PlayerMessagesService {
constructor(private prisma: PrismaService) {}
private buildDepositMessage(
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO'>,
locale: string | null | undefined,
payload: DepositMessagePayload,
) {
const resolvedLocale = resolveLocale(locale);
const templates = MESSAGE_TEMPLATES[type];
const template = templates[resolvedLocale] ?? templates['en-US'];
return {
type,
title: template.title,
body: template.body(payload),
payload,
};
}
async createDepositApprovedMessage(
userId: bigint,
data: {
depositOrderId: bigint;
orderNo: string;
amount: string;
approvedAmount: string;
locale?: string | null;
},
client: Prisma.TransactionClient | PrismaService = this.prisma,
) {
const payload: DepositMessagePayload = {
depositOrderId: data.depositOrderId.toString(),
orderNo: data.orderNo,
amount: data.amount,
approvedAmount: data.approvedAmount,
};
const content = this.buildDepositMessage('DEPOSIT_APPROVED', data.locale, payload);
const row = await client.playerMessage.create({
data: {
userId,
type: content.type,
title: content.title,
body: content.body,
payload: content.payload as Prisma.InputJsonValue,
},
});
return mapMessageRow(row);
}
async createDepositRejectedMessage(
userId: bigint,
data: {
depositOrderId: bigint;
orderNo: string;
amount: string;
rejectReason?: string | null;
locale?: string | null;
},
client: Prisma.TransactionClient | PrismaService = this.prisma,
) {
const payload: DepositMessagePayload = {
depositOrderId: data.depositOrderId.toString(),
orderNo: data.orderNo,
amount: data.amount,
rejectReason: data.rejectReason ?? null,
};
const content = this.buildDepositMessage('DEPOSIT_REJECTED', data.locale, payload);
const row = await client.playerMessage.create({
data: {
userId,
type: content.type,
title: content.title,
body: content.body,
payload: content.payload as Prisma.InputJsonValue,
},
});
return mapMessageRow(row);
}
async broadcastBannerPromotion(data: {
contentId: bigint;
translations: ContentTranslationLike[];
}) {
const players = await this.prisma.user.findMany({
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
select: {
id: true,
locale: true,
preferences: { select: { locale: true } },
},
});
if (!players.length) return 0;
const rows = players.map((player) => {
const playerLocale = player.preferences?.locale ?? player.locale;
const content = buildBannerPromoMessage(
playerLocale,
data.contentId,
data.translations,
);
return {
userId: player.id,
type: content.type,
title: content.title,
body: content.body,
payload: content.payload as Prisma.InputJsonValue,
};
});
const batchSize = 200;
for (let i = 0; i < rows.length; i += batchSize) {
await this.prisma.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
}
return rows.length;
}
async broadcastAnnouncementPromotion(data: {
contentId: bigint;
translations: ContentTranslationLike[];
}) {
const players = await this.prisma.user.findMany({
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
select: {
id: true,
locale: true,
preferences: { select: { locale: true } },
},
});
if (!players.length) return 0;
const rows = players.map((player) => {
const playerLocale = player.preferences?.locale ?? player.locale;
const content = buildAnnouncementPromoMessage(
playerLocale,
data.contentId,
data.translations,
);
return {
userId: player.id,
type: content.type,
title: content.title,
body: content.body,
payload: content.payload as Prisma.InputJsonValue,
};
});
const batchSize = 200;
for (let i = 0; i < rows.length; i += batchSize) {
await this.prisma.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
}
return rows.length;
}
async listForPlayer(userId: bigint, page = 1, pageSize = 20) {
const safePage = Math.max(1, page);
const safePageSize = Math.min(50, Math.max(1, pageSize));
const skip = (safePage - 1) * safePageSize;
const where = { userId };
const [rows, total, unreadCount] = await Promise.all([
this.prisma.playerMessage.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: safePageSize,
}),
this.prisma.playerMessage.count({ where }),
this.prisma.playerMessage.count({ where: { ...where, readAt: null } }),
]);
return {
items: rows.map(mapMessageRow),
total,
unreadCount,
page: safePage,
pageSize: safePageSize,
};
}
async getForPlayer(userId: bigint, messageId: bigint) {
const row = await this.prisma.playerMessage.findFirst({
where: { id: messageId, userId },
});
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
return mapMessageRow(row);
}
async markRead(userId: bigint, messageId: bigint) {
const row = await this.prisma.playerMessage.findFirst({
where: { id: messageId, userId },
});
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
if (row.readAt) return mapMessageRow(row);
const updated = await this.prisma.playerMessage.update({
where: { id: messageId },
data: { readAt: new Date() },
});
return mapMessageRow(updated);
}
async markAllRead(userId: bigint) {
const result = await this.prisma.playerMessage.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
});
return { updated: result.count };
}
async getUnreadCount(userId: bigint) {
const unreadCount = await this.prisma.playerMessage.count({
where: { userId, readAt: null },
});
return { unreadCount };
}
async deleteForPlayer(userId: bigint, messageId: bigint) {
const row = await this.prisma.playerMessage.findFirst({
where: { id: messageId, userId },
});
if (!row) throw appNotFound('MESSAGE_NOT_FOUND');
await this.prisma.playerMessage.delete({ where: { id: messageId } });
return { deleted: true, wasUnread: row.readAt == null };
}
async deleteAllForPlayer(userId: bigint) {
const result = await this.prisma.playerMessage.deleteMany({ where: { userId } });
return { deleted: result.count };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { RedisModule } from '../../shared/redis/redis.module';
import { PresenceService } from './presence.service';
@Module({
imports: [RedisModule],
providers: [PresenceService],
exports: [PresenceService],
})
export class PresenceModule {}

View File

@@ -0,0 +1,70 @@
import { PresenceService } from './presence.service';
describe('PresenceService', () => {
const pipeline = {
exists: jest.fn().mockReturnThis(),
exec: jest.fn(),
};
const redis = {
set: jest.fn(),
exists: jest.fn(),
raw: {
scan: jest.fn(),
pipeline: jest.fn(() => pipeline),
},
};
let service: PresenceService;
beforeEach(() => {
jest.clearAllMocks();
service = new PresenceService(redis as never);
});
it('touches player key with 120s TTL', async () => {
await service.touch(42n);
expect(redis.set).toHaveBeenCalledWith('presence:player:42', '1', 120);
});
it('checks single player online status', async () => {
redis.exists.mockResolvedValue(true);
await expect(service.isOnline(7n)).resolves.toBe(true);
expect(redis.exists).toHaveBeenCalledWith('presence:player:7');
});
it('counts online keys via SCAN', async () => {
redis.raw.scan
.mockResolvedValueOnce(['1', ['presence:player:1', 'presence:player:2']])
.mockResolvedValueOnce(['0', ['presence:player:3']]);
await expect(service.getOnlineCount()).resolves.toBe(3);
expect(redis.raw.scan).toHaveBeenCalledWith(
'0',
'MATCH',
'presence:player:*',
'COUNT',
200,
);
});
it('filters online ids with pipeline exists', async () => {
pipeline.exec.mockResolvedValue([
[null, 1],
[null, 0],
[null, 1],
]);
const result = await service.filterOnlineIds([10n, 20n, 30n]);
expect(result).toEqual(new Set(['10', '30']));
expect(pipeline.exists).toHaveBeenCalledTimes(3);
expect(pipeline.exists).toHaveBeenNthCalledWith(1, 'presence:player:10');
expect(pipeline.exists).toHaveBeenNthCalledWith(2, 'presence:player:20');
expect(pipeline.exists).toHaveBeenNthCalledWith(3, 'presence:player:30');
});
it('returns empty set when no ids provided', async () => {
await expect(service.filterOnlineIds([])).resolves.toEqual(new Set());
expect(redis.raw.pipeline).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '../../shared/redis/redis.service';
const TTL_SECONDS = 120;
const KEY_PREFIX = 'presence:player:';
function keyFor(userId: bigint): string {
return `${KEY_PREFIX}${userId.toString()}`;
}
@Injectable()
export class PresenceService {
constructor(private readonly redis: RedisService) {}
async touch(userId: bigint): Promise<void> {
await this.redis.set(keyFor(userId), '1', TTL_SECONDS);
}
async isOnline(userId: bigint): Promise<boolean> {
return this.redis.exists(keyFor(userId));
}
async getOnlineCount(): Promise<number> {
let cursor = '0';
let count = 0;
do {
const [next, keys] = await this.redis.raw.scan(
cursor,
'MATCH',
`${KEY_PREFIX}*`,
'COUNT',
200,
);
cursor = next;
count += keys.length;
} while (cursor !== '0');
return count;
}
async filterOnlineIds(ids: bigint[]): Promise<Set<string>> {
const online = new Set<string>();
if (ids.length === 0) return online;
const pipeline = this.redis.raw.pipeline();
for (const id of ids) {
pipeline.exists(keyFor(id));
}
const results = await pipeline.exec();
ids.forEach((id, index) => {
const entry = results?.[index];
if (!entry) return;
const [err, value] = entry;
if (!err && value === 1) online.add(id.toString());
});
return online;
}
}

View File

@@ -9,6 +9,17 @@ export const AGENT_MAX_LEVEL = 'agent.max_level';
export const AGENT_DEFAULT_SUB_CREDIT_RATIO = 'agent.default_sub_credit_ratio';
export const CASHBACK_PLATFORM_DIRECT_RATE = 'cashback.platform_direct_rate';
export const CASHBACK_ADMIN_INVITE_RATE = 'cashback.admin_invite_rate';
export const INBOX_NOTIFY_DEPOSIT = 'inbox.notify.deposit';
export const INBOX_FEATURE_ENABLED = 'inbox.feature_enabled';
export const INBOX_NOTIFY_BANNER = 'inbox.notify.banner';
export const INBOX_NOTIFY_ANNOUNCEMENT = 'inbox.notify.announcement';
export type InboxNotifySettings = {
/** 玩家端是否展示站内邮箱(关闭后入口直达客服) */
inboxEnabled: boolean;
/** 充值审核通过/拒绝时发送站内信 */
deposit: boolean;
};
export type PlatformDirectCashbackSettings = {
/** 平台直属玩家默认返水比例小数0.01 = 1% */
@@ -222,4 +233,34 @@ export class SystemConfigService {
}
return this.getPlatformDirectCashbackSettings();
}
async getInboxFeatureEnabled(): Promise<boolean> {
return this.getBoolean(INBOX_FEATURE_ENABLED, true);
}
async getInboxNotifySettings(): Promise<InboxNotifySettings> {
const [inboxEnabled, deposit] = await Promise.all([
this.getBoolean(INBOX_FEATURE_ENABLED, true),
this.getBoolean(INBOX_NOTIFY_DEPOSIT, true),
]);
return { inboxEnabled, deposit };
}
async updateInboxNotifySettings(data: Partial<InboxNotifySettings>) {
if (data.inboxEnabled !== undefined) {
await this.setBoolean(
INBOX_FEATURE_ENABLED,
data.inboxEnabled,
'玩家端是否开启站内邮箱功能',
);
}
if (data.deposit !== undefined) {
await this.setBoolean(
INBOX_NOTIFY_DEPOSIT,
data.deposit,
'充值审核结果是否通过站内邮箱通知玩家',
);
}
return this.getInboxNotifySettings();
}
}

View File

@@ -3,13 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="theme-color" content="#003D6B" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>TheBet365</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; background: #000; }
html, body { margin: 0; padding: 0; height: 100%; background: #F5F7FA; }
#app-loading {
position: fixed;
inset: 0;
@@ -17,7 +17,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: #000;
background: #F5F7FA;
z-index: 9999;
}
#app-loading svg {
@@ -35,16 +35,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" />
@@ -54,9 +54,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" />
@@ -69,4 +69,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

View File

@@ -1,23 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const router = useRouter();
const props = withDefaults(
defineProps<{ items: string[]; embedded?: boolean }>(),
defineProps<{ items: string[]; targetId?: string; embedded?: boolean }>(),
{ embedded: false },
);
const detailTo = computed(() =>
props.targetId ? `/announcements/${props.targetId}` : '/announcements',
);
const text = computed(() => {
const list = props.items.filter(Boolean);
if (!list.length) return '';
return list.join('  ◆  ');
});
function goDetail() {
void router.push(detailTo.value);
}
</script>
<template>
<div v-if="text" class="marquee-bar" :class="{ embedded }">
<button
v-if="text"
type="button"
class="marquee-bar"
:class="{ embedded }"
@click="goDetail"
>
<span class="marquee-badge">{{ t('home.announcement_badge') }}</span>
<div class="marquee-viewport">
<div class="marquee-track">
@@ -25,20 +41,26 @@ const text = computed(() => {
<span class="marquee-text" aria-hidden="true">{{ text }}</span>
</div>
</div>
</div>
</button>
</template>
<style scoped>
.marquee-bar {
width: 100%;
display: flex;
align-items: center;
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;
text-decoration: none;
color: inherit;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.marquee-bar.embedded {
@@ -47,7 +69,7 @@ const text = computed(() => {
border: none;
border-radius: 0;
border-bottom: 1px solid var(--border);
background: #111;
background: #EFF6FF;
}
.marquee-bar.embedded .marquee-badge {
@@ -65,9 +87,9 @@ const text = computed(() => {
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;
}
@@ -82,6 +104,7 @@ const text = computed(() => {
display: flex;
width: max-content;
animation: marquee-scroll 18s linear infinite;
pointer-events: none;
}
.marquee-text {

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

@@ -13,7 +13,11 @@ export interface BannerItem {
translation?: { title?: string; body?: string; imageUrl?: string };
}
const props = defineProps<{ banners: BannerItem[] }>();
const props = defineProps<{
banners: BannerItem[];
/** 未配置跳转链接时的默认路由 */
fallbackTo?: string;
}>();
const router = useRouter();
const active = ref(0);
@@ -51,14 +55,12 @@ function prev() {
}
function onBannerClick(banner: BannerItem) {
if (banner.linkType === 'ROUTE' && banner.linkTarget) {
router.push(banner.linkTarget);
if (banner.id) {
void router.push(`/announcements/${banner.id}`);
return;
}
if (banner.linkType === 'URL' && banner.linkTarget) {
let url = banner.linkTarget.trim();
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
window.open(url, '_blank');
if (props.fallbackTo) {
void router.push(props.fallbackTo);
}
}
@@ -166,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 {
@@ -197,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;
}
@@ -209,8 +209,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;
}
@@ -221,7 +221,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;
}
@@ -244,29 +244,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; }
@@ -274,29 +275,30 @@ onUnmounted(stopAutoPlay);
.media .dots {
position: absolute;
bottom: 10px;
bottom: 8px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 8px;
gap: 6px;
z-index: 2;
}
.dot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
border: 2px solid transparent;
background: rgba(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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { PARLAY_MIN_LEGS, PARLAY_MAX_LEGS } from '@thebet365/shared';
import {
@@ -11,6 +11,7 @@ import {
import { useAuthStore } from '../stores/auth';
import { formatMoney, parseAmount } from '../utils/localeDisplay';
import BetSuccessOverlay from './BetSuccessOverlay.vue';
import ConfirmDialog from './ConfirmDialog.vue';
import api from '../api';
import { usePlayerProfile } from '../composables/usePlayerProfile';
@@ -37,6 +38,28 @@ const MIN_STAKE = 5;
const MAX_STAKE_INTEGER_LENGTH = 9;
const stakeInput = ref('');
const keypadKeys = ['1', '2', '3', '4', '5', 'backspace', '6', '7', '8', '9', '0', '00'];
const ODDS_POLL_MS = 5000;
type OddsDelta = {
oldOdds: number;
newOdds: number;
newVersion: string;
suspended: boolean;
};
interface SelectionOddsRow {
id: string;
odds: string;
oddsVersion: string;
status: string;
marketStatus: string;
marketShowOnPlayer: boolean;
matchStatus: string;
}
const oddsDeltas = ref<Record<string, OddsDelta>>({});
let oddsPollTimer: ReturnType<typeof setInterval> | null = null;
const clearConfirmVisible = ref(false);
const activeItems = computed<SlipItem[]>(() => {
if (activeTab.value === 'parlay') return slip.parlayItems;
@@ -44,22 +67,57 @@ const activeItems = computed<SlipItem[]>(() => {
});
const activeCount = computed(() => activeItems.value.length);
function effectiveOdds(item: SlipItem) {
return oddsDeltas.value[item.selectionId]?.newOdds ?? item.odds;
}
const activeTotalOdds = computed(() =>
activeItems.value.reduce((acc, item) => acc * item.odds, 1),
activeItems.value.reduce((acc, item) => acc * effectiveOdds(item), 1),
);
const activeEstimatedReturn = computed(() => {
if (!activeItems.value.length || !Number.isFinite(slip.stake) || slip.stake <= 0) return 0;
if (activeTab.value === 'parlay') return slip.stake * activeTotalOdds.value;
return slip.stake * activeItems.value[0].odds;
return slip.stake * effectiveOdds(activeItems.value[0]);
});
const hasSuspendedSelections = computed(() =>
Object.values(oddsDeltas.value).some((delta) => delta.suspended),
);
const hasPendingOddsChanges = computed(() =>
Object.values(oddsDeltas.value).some((delta) => !delta.suspended),
);
const oddsWarningText = computed(() => {
if (hasSuspendedSelections.value) return t('bet.odds_suspended');
if (hasPendingOddsChanges.value) return t('bet.odds_changed');
return '';
});
const submitButtonLabel = computed(() => {
if (loading.value) return t('bet.placing');
if (hasPendingOddsChanges.value) return t('bet.accept_changes_place');
return t('bet.place_bet_short');
});
const canSubmitWithOdds = computed(() => canSubmitActive.value && !hasSuspendedSelections.value);
const canSubmitActive = computed(() => {
if (activeTab.value === 'parlay') {
return slip.parlayItems.length >= PARLAY_MIN_LEGS && slip.parlayItems.length <= PARLAY_MAX_LEGS;
}
return Boolean(slip.singleItem);
return Boolean(slip.singleItem) && slip.singleItem!.allowSingle !== false;
});
const singleParlayOnlyHint = computed(
() =>
activeTab.value === 'single' &&
Boolean(slip.singleItem) &&
slip.singleItem!.allowSingle === false &&
slip.singleItem!.allowParlay !== false,
);
const balanceText = computed(() => {
if (balanceLoading.value) return t('bet.loading');
if (balance.value == null) return '--';
@@ -214,6 +272,93 @@ function setMaxStake() {
if (balance.value != null && balance.value > 0) setStake(balance.value, false);
}
function stopOddsPolling() {
if (oddsPollTimer) {
clearInterval(oddsPollTimer);
oddsPollTimer = null;
}
oddsDeltas.value = {};
}
function acceptPendingOdds() {
for (const [selectionId, delta] of Object.entries(oddsDeltas.value)) {
if (!delta.suspended) {
slip.updateSelectionOdds(selectionId, delta.newOdds, delta.newVersion);
}
}
oddsDeltas.value = {};
}
async function pollSelectionsOdds() {
const items = activeItems.value;
if (!items.length || !show.value) return;
try {
const ids = items.map((item) => item.selectionId).join(',');
const { data } = await api.get('/player/selections/odds', { params: { ids } });
const rows: SelectionOddsRow[] = data.data?.items ?? [];
const rowMap = new Map(rows.map((row) => [row.id, row]));
const next: Record<string, OddsDelta> = {};
for (const item of items) {
const row = rowMap.get(item.selectionId);
if (!row) continue;
const suspended =
row.status !== 'OPEN' ||
row.marketStatus !== 'OPEN' ||
row.marketShowOnPlayer === false ||
row.matchStatus !== 'PUBLISHED';
const newOdds = parseFloat(row.odds);
const versionChanged = row.oddsVersion !== item.oddsVersion;
const oddsChanged = Number.isFinite(newOdds) && Math.abs(newOdds - item.odds) > 0.0001;
if (suspended || versionChanged || oddsChanged) {
const existing = oddsDeltas.value[item.selectionId];
next[item.selectionId] = {
oldOdds: existing?.oldOdds ?? item.odds,
newOdds: Number.isFinite(newOdds) ? newOdds : item.odds,
newVersion: row.oddsVersion,
suspended,
};
}
}
oddsDeltas.value = next;
} catch {
/* silent retry on next tick */
}
}
function startOddsPolling() {
stopOddsPolling();
void pollSelectionsOdds();
oddsPollTimer = setInterval(() => {
void pollSelectionsOdds();
}, ODDS_POLL_MS);
}
function oddsDeltaFor(selectionId: string) {
return oddsDeltas.value[selectionId];
}
function oddsTrendClass(delta: OddsDelta) {
if (delta.suspended) return 'odds-suspended';
return delta.newOdds >= delta.oldOdds ? 'odds-up' : 'odds-down';
}
function onClearSlip() {
if (!activeItems.value.length) return;
clearConfirmVisible.value = true;
}
function confirmClearSlip() {
if (activeTab.value === 'parlay') slip.clearParlay();
else slip.clearSingle();
clearConfirmVisible.value = false;
error.value = '';
}
async function placeBet() {
if (!activeItems.value.length) return;
if (!auth.token) {
@@ -234,6 +379,13 @@ async function placeBet() {
: t('bet.parlay_need_more');
return;
}
if (hasSuspendedSelections.value) {
error.value = t('bet.odds_suspended');
return;
}
if (hasPendingOddsChanges.value) {
acceptPendingOdds();
}
loading.value = true;
error.value = '';
@@ -279,7 +431,10 @@ async function placeBet() {
watch(
() => props.modelValue,
(open) => {
if (!open) return;
if (!open) {
stopOddsPolling();
return;
}
activeTab.value = slip.mode;
if (activeTab.value === 'single' && !slip.singleItem && slip.parlayItems.length) {
activeTab.value = 'parlay';
@@ -289,9 +444,21 @@ watch(
success.value = '';
syncStakeInputFromSlip();
loadBalance();
startOddsPolling();
},
);
watch(
() => activeItems.value.map((item) => item.selectionId).join(','),
() => {
if (show.value) void pollSelectionsOdds();
},
);
onUnmounted(() => {
stopOddsPolling();
});
watch(
() => slip.mode,
(mode) => {
@@ -321,6 +488,8 @@ watch(
</button>
</div>
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<div class="slip-tabs">
<button
type="button"
@@ -354,8 +523,21 @@ watch(
<div v-if="slip.singleItem.marketName" class="item-market">{{ slip.singleItem.marketName }}</div>
<div class="item-pick">{{ slip.singleItem.selectionName }}</div>
</div>
<div class="item-odds">{{ slip.singleItem.odds.toFixed(2) }}</div>
<div class="item-odds">
<template v-if="oddsDeltaFor(slip.singleItem.selectionId)">
<span
class="odds-change"
:class="oddsTrendClass(oddsDeltaFor(slip.singleItem.selectionId)!)"
>
{{ oddsDeltaFor(slip.singleItem.selectionId)!.oldOdds.toFixed(2) }}
{{ oddsDeltaFor(slip.singleItem.selectionId)!.newOdds.toFixed(2) }}
</span>
</template>
<template v-else>{{ slip.singleItem.odds.toFixed(2) }}</template>
</div>
</div>
<p v-if="singleParlayOnlyHint" class="warning">{{ t('bet.slip_parlay_only_hint') }}</p>
<template v-if="activeTab === 'parlay'">
<p v-if="parlayWarning" class="warning">{{ parlayWarning }}</p>
@@ -370,7 +552,19 @@ watch(
<div class="item-pick">{{ item.selectionName }}</div>
</div>
<div class="item-side">
<strong>{{ item.odds.toFixed(2) }}</strong>
<strong>
<template v-if="oddsDeltaFor(item.selectionId)">
<span
class="odds-change"
:class="oddsTrendClass(oddsDeltaFor(item.selectionId)!)"
>
{{ oddsDeltaFor(item.selectionId)!.oldOdds.toFixed(2) }}
{{ oddsDeltaFor(item.selectionId)!.newOdds.toFixed(2) }}
</span>
</template>
<template v-else>{{ item.odds.toFixed(2) }}</template>
</strong>
<button type="button" class="remove" @click="removeItem(item.selectionId)">
{{ t('bet.slip_remove') }}
</button>
@@ -441,23 +635,32 @@ watch(
<button
type="button"
class="btn-primary"
:disabled="loading || !canSubmitActive"
:disabled="loading || !canSubmitWithOdds"
@click="placeBet"
>
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
{{ submitButtonLabel }}
</button>
</div>
</div>
</div>
<BetSuccessOverlay :show="showSuccess" @done="onSuccessDone" />
<ConfirmDialog
v-model:visible="clearConfirmVisible"
:title="t('bet.slip_clear_title', '清空投注单')"
:message="t('bet.slip_clear_confirm', '确认清空投注单?')"
:confirm-text="t('bet.slip_clear_confirm_btn', '清空')"
danger
@confirm="confirmClearSlip"
/>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
background: rgba(0, 0, 0, 0.35);
z-index: 200;
display: flex;
align-items: flex-end;
@@ -466,13 +669,13 @@ watch(
.drawer {
width: 100%;
max-height: 88vh;
background: #101010;
background: #FFFFFF;
border-radius: 16px 16px 0 0;
border-top: 1px solid var(--border-gold-soft);
border-top: 1px solid #E5E7EB;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 -20px 44px rgba(0, 0, 0, 0.48);
box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.12);
}
.drawer-head {
@@ -484,14 +687,14 @@ 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: #FFFFFF;
border-bottom: 1px solid #E5E7EB;
}
.drawer-kicker {
margin: 0 0 3px;
font-size: 11px;
color: var(--primary-light);
color: #003D6B;
font-weight: 800;
}
@@ -499,7 +702,7 @@ watch(
margin: 0;
font-size: 17px;
font-weight: 900;
color: var(--text);
color: #1A1A2E;
}
.close-btn {
@@ -509,8 +712,8 @@ watch(
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.05);
color: var(--text-muted);
background: #F5F7FA;
color: #6B7280;
font-size: 18px;
padding: 0;
pointer-events: auto;
@@ -523,15 +726,15 @@ 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);
color: var(--text-muted);
background: rgba(0, 61, 107, 0.04);
border-bottom: 1px solid #E5E7EB;
color: #6B7280;
font-size: 12px;
}
.balance-bar strong {
justify-self: end;
color: var(--primary-light);
color: #003D6B;
font-size: 16px;
font-weight: 900;
}
@@ -540,8 +743,8 @@ watch(
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.04);
color: var(--text-muted);
background: #F5F7FA;
color: #6B7280;
padding: 0;
}
@@ -550,23 +753,23 @@ watch(
grid-template-columns: repeat(2, 1fr);
padding: 8px 12px 0;
gap: 6px;
background: #101010;
background: #FFFFFF;
}
.slip-tab {
min-height: 38px;
border-radius: 6px 6px 0 0;
border: 1px solid var(--border);
background: #171717;
color: var(--text-muted);
border: 1px solid #E5E7EB;
background: #F5F7FA;
color: #6B7280;
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: #003D6B;
background: rgba(0, 61, 107, 0.06);
color: #003D6B;
}
.tab-count {
@@ -577,8 +780,8 @@ watch(
height: 18px;
margin-left: 6px;
border-radius: 9px;
background: var(--primary);
color: #111;
background: #003D6B;
color: #FFFFFF;
font-size: 11px;
font-weight: 900;
}
@@ -592,7 +795,7 @@ watch(
.empty {
text-align: center;
color: var(--text-muted);
color: #6B7280;
padding: 26px 12px;
font-size: 13px;
}
@@ -603,15 +806,15 @@ watch(
gap: 10px;
align-items: center;
padding: 11px 12px;
background: #151515;
border: 1px solid #292929;
border-radius: 7px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
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: #003D6B;
background: rgba(0, 61, 107, 0.03);
}
.item-main {
@@ -621,7 +824,7 @@ watch(
.item-title {
font-size: 13px;
font-weight: 900;
color: var(--text);
color: #1A1A2E;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -631,19 +834,19 @@ watch(
margin-top: 4px;
font-size: 12px;
font-weight: 800;
color: var(--primary-light);
color: #003D6B;
}
.item-pick {
margin-top: 3px;
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
line-height: 1.35;
}
.item-odds,
.item-side strong {
color: var(--primary-light);
color: #003D6B;
font-size: 22px;
font-weight: 900;
}
@@ -657,7 +860,7 @@ watch(
.remove {
background: none;
color: var(--danger);
color: #DC2626;
font-size: 12px;
font-weight: 800;
padding: 0;
@@ -672,14 +875,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: #FFFFFF;
border: 1px solid #003D6B;
color: #003D6B;
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 1px 3px rgba(0, 0, 0, 0.08);
}
.btn-add-parlay span {
@@ -689,8 +890,8 @@ watch(
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary-light);
color: #191100;
background: #003D6B;
color: #FFFFFF;
font-size: 14px;
line-height: 1;
}
@@ -700,16 +901,16 @@ watch(
margin: 0 0 10px;
padding: 10px 11px;
border-radius: 6px;
background: rgba(255, 77, 79, 0.12);
border: 1px solid rgba(255, 77, 79, 0.25);
color: #ff8b8b;
background: rgba(220, 38, 38, 0.06);
border: 1px solid rgba(220, 38, 38, 0.18);
color: #DC2626;
font-size: 12px;
line-height: 1.45;
}
.parlay-meta {
margin: 8px 2px 10px;
color: var(--primary-light);
color: #003D6B;
font-size: 12px;
font-weight: 800;
}
@@ -717,8 +918,8 @@ watch(
.stake-area {
margin: 12px 0 12px;
padding: 12px;
background: #151515;
border: 1px solid #292929;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
}
@@ -729,12 +930,12 @@ watch(
justify-content: space-between;
gap: 8px;
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
}
.stake-head strong,
.return strong {
color: var(--primary-light);
color: #003D6B;
font-size: 16px;
font-weight: 900;
}
@@ -744,17 +945,17 @@ watch(
grid-template-columns: auto 1fr auto;
align-items: center;
margin-top: 10px;
border: 1px solid var(--border-gold-soft);
border: 1px solid #E5E7EB;
border-radius: 6px;
overflow: hidden;
background: #0c0c0c;
background: #F5F7FA;
}
.currency {
padding: 0 10px;
color: var(--text-muted);
color: #6B7280;
font-size: 13px;
border-right: 1px solid #2d2d2d;
border-right: 1px solid #E5E7EB;
}
.stake-input-row input {
@@ -762,7 +963,7 @@ watch(
height: 42px;
border: none;
background: transparent;
color: var(--text);
color: #1A1A2E;
font-size: 17px;
font-weight: 800;
padding: 0 10px;
@@ -781,7 +982,7 @@ watch(
width: 38px;
height: 42px;
background: transparent;
color: var(--text-muted);
color: #6B7280;
padding: 0;
font-size: 15px;
}
@@ -804,10 +1005,10 @@ watch(
.number-key,
.quick-stakes button {
min-height: 52px;
border-radius: 5px;
background: #f4f1f6;
color: #221f26;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 6px;
background: #F5F7FA;
color: #1A1A2E;
border: 1px solid #E5E7EB;
font-size: 26px;
font-weight: 500;
}
@@ -819,10 +1020,11 @@ watch(
.quick-stakes button {
min-height: 50px;
background: #7b787d;
color: var(--text);
background: #FFFFFF;
color: #1A1A2E;
font-size: 20px;
font-weight: 800;
border: 1px solid #E5E7EB;
}
.return {
@@ -830,7 +1032,7 @@ watch(
}
.success {
color: var(--primary-light);
color: #059669;
font-size: 13px;
margin: 0 0 8px;
}
@@ -841,9 +1043,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: #FFFFFF;
border-top: 1px solid #E5E7EB;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.drawer-foot--split {
@@ -853,9 +1055,9 @@ watch(
.btn-primary {
width: 100%;
min-height: 48px;
border-radius: 7px;
background: linear-gradient(180deg, var(--primary-light), var(--primary));
color: #111;
border-radius: 8px;
background: #003D6B;
color: #FFFFFF;
font-size: 16px;
font-weight: 900;
}
@@ -863,4 +1065,34 @@ watch(
.btn-primary:disabled {
opacity: 0.45;
}
.odds-warning {
margin: 0;
padding: 8px 14px;
font-size: 12px;
font-weight: 600;
background: rgba(234, 179, 8, 0.08);
color: #92400e;
border-bottom: 1px solid rgba(234, 179, 8, 0.2);
}
.odds-change {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 700;
}
.odds-up {
color: #16a34a;
}
.odds-down {
color: #dc2626;
}
.odds-suspended {
color: #6b7280;
text-decoration: line-through;
}
</style>

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

@@ -115,10 +115,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 {
@@ -130,7 +131,7 @@ onUnmounted(() => {
.chip-label {
font-size: 8px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
@@ -143,14 +144,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;
}
@@ -166,10 +167,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 {
@@ -183,21 +184,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;
}
@@ -206,9 +208,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;
@@ -216,7 +218,7 @@ onUnmounted(() => {
}
.panel-recharge-btn:active {
background: rgba(200, 168, 78, 0.24);
background: rgba(0, 61, 107, 0.06);
}
.backdrop {

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = withDefaults(
defineProps<{
visible: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
loading?: boolean;
}>(),
{
danger: false,
loading: false,
},
);
const emit = defineEmits<{
'update:visible': [value: boolean];
confirm: [];
cancel: [];
}>();
const { t } = useI18n();
const resolvedTitle = computed(() => props.title ?? t('common.confirm'));
const resolvedConfirmText = computed(() => props.confirmText ?? t('common.confirm'));
const resolvedCancelText = computed(() => props.cancelText ?? t('common.cancel'));
function close() {
if (props.loading) return;
emit('update:visible', false);
emit('cancel');
}
function onConfirm() {
if (props.loading) return;
emit('confirm');
}
</script>
<template>
<Teleport to="body">
<Transition name="confirm-fade">
<div
v-if="visible"
class="confirm-overlay"
@click.self="close"
>
<div
class="confirm-modal"
role="alertdialog"
aria-modal="true"
:aria-labelledby="title ? 'confirm-dialog-title' : undefined"
:aria-describedby="'confirm-dialog-message'"
>
<h2 v-if="title" id="confirm-dialog-title" class="confirm-title">{{ resolvedTitle }}</h2>
<p id="confirm-dialog-message" class="confirm-message">{{ message }}</p>
<div class="confirm-actions">
<button
type="button"
class="confirm-btn cancel"
:disabled="loading"
@click="close"
>
{{ resolvedCancelText }}
</button>
<button
type="button"
class="confirm-btn confirm"
:class="{ danger }"
:disabled="loading"
@click="onConfirm"
>
{{ resolvedConfirmText }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.confirm-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.48);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.confirm-modal {
width: 100%;
max-width: 340px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 22px 18px 16px;
box-shadow: 0 8px 32px rgba(0, 61, 107, 0.12);
}
.confirm-title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 800;
color: var(--primary);
text-align: center;
line-height: 1.35;
}
.confirm-message {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.6;
color: var(--text-muted);
text-align: center;
}
.confirm-actions {
display: flex;
gap: 10px;
}
.confirm-btn {
flex: 1;
min-height: 44px;
border-radius: 8px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.15s;
}
.confirm-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.confirm-btn.cancel {
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
}
.confirm-btn.confirm {
border: none;
background: var(--primary);
color: #fff;
}
.confirm-btn.confirm.danger {
background: #dc2626;
color: #fff;
}
.confirm-fade-enter-active,
.confirm-fade-leave-active {
transition: opacity 0.2s ease;
}
.confirm-fade-enter-active .confirm-modal,
.confirm-fade-leave-active .confirm-modal {
transition: transform 0.2s ease;
}
.confirm-fade-enter-from,
.confirm-fade-leave-to {
opacity: 0;
}
.confirm-fade-enter-from .confirm-modal,
.confirm-fade-leave-to .confirm-modal {
transform: scale(0.96);
}
</style>

View File

@@ -1,165 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildCustomerServiceUrl } from '../config/customerService';
import { useAuthStore } from '../stores/auth';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const props = defineProps<{ modelValue: boolean }>();
const emit = defineEmits<{ 'update:modelValue': [boolean] }>();
const { t } = useI18n();
const auth = useAuthStore();
const { profileRaw, avatarUrl } = usePlayerProfile();
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
const iframeSrc = computed(() => {
const visitor = auth.user
? {
name:
profileRaw.value?.username ||
profileRaw.value?.preferences?.phone ||
auth.user.username ||
'',
avatar: avatarUrl.value
? new URL(avatarUrl.value, window.location.origin).href
: '',
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
}
: null;
return buildCustomerServiceUrl(t('support.connecting'), visitor);
});
function close() {
visible.value = false;
}
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div v-if="visible" class="cs-overlay" @click.self="close">
<div class="cs-modal" role="dialog" :aria-label="t('support.title')">
<header class="cs-header">
<h2 class="cs-title">{{ t('support.title') }}</h2>
<button type="button" class="close-btn" :aria-label="t('support.close')" @click="close">
</button>
</header>
<div class="cs-body">
<iframe
v-if="visible"
:key="iframeSrc"
class="cs-frame"
:src="iframeSrc"
:title="t('support.title')"
allow="microphone; camera; clipboard-read; clipboard-write"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.cs-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.cs-modal {
display: flex;
flex-direction: column;
width: min(100%, 420px);
height: min(82vh, 680px);
background: #141414;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
border-radius: 12px;
overflow: hidden;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.55);
}
.cs-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border, #2a2a2a);
background: rgba(26, 26, 26, 0.98);
}
.cs-title {
margin: 0;
font-size: 16px;
font-weight: 800;
color: var(--primary-light, #c8a84e);
}
.close-btn {
background: none;
border: none;
color: #666;
font-size: 18px;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.close-btn:hover {
color: #aaa;
}
.cs-body {
flex: 1;
min-height: 0;
background: #0d0d0d;
}
.cs-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
background: #fff;
}
.cs-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
margin: 0;
padding: 24px;
text-align: center;
font-size: 14px;
line-height: 1.6;
color: #888;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.25s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { buildCustomerServiceUrl } from '../config/customerService';
import { useAuthStore } from '../stores/auth';
import { usePlayerProfile } from '../composables/usePlayerProfile';
const { t } = useI18n();
const auth = useAuthStore();
const { profileRaw, avatarUrl } = usePlayerProfile();
const iframeSrc = computed(() => {
const visitor = auth.user
? {
name:
profileRaw.value?.username ||
profileRaw.value?.preferences?.phone ||
auth.user.username ||
'',
avatar: avatarUrl.value
? new URL(avatarUrl.value, window.location.origin).href
: '',
id: String(profileRaw.value?.id ?? auth.user.id ?? ''),
}
: null;
return buildCustomerServiceUrl(t('support.connecting'), visitor);
});
</script>
<template>
<div class="cs-panel">
<iframe
:key="iframeSrc"
class="cs-frame"
:src="iframeSrc"
:title="t('support.title')"
allow="microphone; camera; clipboard-read; clipboard-write"
/>
</div>
</template>
<style scoped>
.cs-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
margin: 0 -16px;
background: var(--bg-body);
}
.cs-frame {
flex: 1;
width: 100%;
min-height: calc(100dvh - 140px);
border: 0;
background: #fff;
}
</style>

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

@@ -1,5 +1,6 @@
<script setup lang="ts">
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';
@@ -40,6 +41,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
const { t } = useI18n();
const totalCount = computed(() => props.matches.length);
const liveCount = computed(() => props.matches.filter(m => m.matchPhase === 'closed_pending').length);
@@ -55,9 +57,9 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
<span class="league-info">
<span class="league-title">*{{ leagueName }}</span>
<span class="league-stats">
<span class="stat-item">{{ totalCount }}</span>
<span v-if="liveCount > 0" class="stat-item stat-live">{{ liveCount }}进行中</span>
<span v-if="openCount > 0" class="stat-item stat-open">{{ openCount }}待开赛</span>
<span class="stat-item">{{ totalCount }} {{ t('bet.match_unit') }}</span>
<span v-if="liveCount > 0" class="stat-item stat-live">{{ liveCount }} {{ t('bet.status_live') }}</span>
<span v-if="openCount > 0" class="stat-item stat-open">{{ openCount }} {{ t('bet.status_open') }}</span>
</span>
</span>
<img
@@ -86,11 +88,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 {
@@ -112,8 +115,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;
}
@@ -127,17 +129,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;
}
@@ -152,8 +154,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;
}
@@ -163,16 +165,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 {
@@ -183,11 +185,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

@@ -79,16 +79,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 {
@@ -105,10 +106,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 {
@@ -117,7 +118,7 @@ onUnmounted(() => {
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
color: var(--text-muted);
color: #6B7280;
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -126,7 +127,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

@@ -78,7 +78,7 @@ const liveScoreText = computed(() => {
:class="{ 'match-card--phase': phase !== 'open' }"
@click="emit('bet', match.id)"
>
<span v-if="phase === 'open'" class="status-tag status-tag--open">待开赛</span>
<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>
@@ -130,30 +130,32 @@ const liveScoreText = computed(() => {
<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 #E5E7EB;
border-radius: 8px;
padding: 8px 6px 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
gap: 6px;
min-width: 0;
overflow: hidden;
cursor: pointer;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
transition: transform 0.15s, box-shadow 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.02) 0%, transparent 50%, rgba(0, 91, 159, 0.02) 100%);
pointer-events: none;
z-index: 0;
}
.match-card--phase {
/* inherits base dark style */
opacity: 0.95;
}
.teams-row {
@@ -170,9 +172,9 @@ const liveScoreText = computed(() => {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
gap: 4px;
min-width: 0;
transform: translateY(8px);
transform: translateY(6px);
}
.team-name {
@@ -180,32 +182,31 @@ const liveScoreText = computed(() => {
display: flex;
align-items: center;
justify-content: center;
font-size: 17px;
font-weight: 900;
color: #fff;
font-size: 14px;
font-weight: 700;
color: #1A1A2E;
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;
}
.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;
}
.team-flag.flag-logo {
width: 48px;
height: 48px;
width: 38px;
height: 38px;
object-fit: contain;
}
@@ -215,37 +216,33 @@ const liveScoreText = computed(() => {
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 56px;
gap: 3px;
min-width: 48px;
}
.kickoff {
font-size: 13px;
font-weight: 700;
color: #fff;
font-size: 12px;
font-weight: 600;
color: #1A1A2E;
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-size: 18px;
font-weight: 900;
color: #fff;
color: #003D6B;
line-height: 1;
letter-spacing: 0.04em;
}
.vs {
font-size: 22px;
font-size: 18px;
font-weight: 900;
color: #fff;
letter-spacing: 0.08em;
color: #003D6B;
line-height: 1;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9);
}
.status-tag {
@@ -254,49 +251,48 @@ 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;
}
.bet-btn--view {
background: #1a1a1a;
border: 1px solid #444;
color: #aaa;
background: #F5F7FA;
border: 1px solid #E5E7EB;
color: #6B7280;
}
</style>

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from './GoldSpinner.vue';
import ConfirmDialog from './ConfirmDialog.vue';
import { useAuthStore } from '../stores/auth';
import { usePlayerMessages, type DepositMessagePayload, type PlayerMessage } from '../composables/usePlayerMessages';
const router = useRouter();
const { t, locale } = useI18n();
const auth = useAuthStore();
const {
messages,
loading,
listLoaded,
loadMessages,
refreshUnreadCount,
deleteMessage,
} = usePlayerMessages();
const page = ref(1);
const total = ref(0);
const deletingId = ref<string | null>(null);
const deleteConfirmVisible = ref(false);
const pendingDeleteId = ref<string | null>(null);
const hasMore = computed(() => messages.value.length < total.value);
function formatDate(createdAt?: string) {
if (!createdAt) return '';
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
}
function messageTitle(item: PlayerMessage) {
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
return item.title;
}
function messagePreview(item: PlayerMessage) {
if (item.type === 'DEPOSIT_APPROVED' || item.type === 'DEPOSIT_REJECTED') {
const deposit = item.payload as DepositMessagePayload | null;
if (deposit?.orderNo) return deposit.orderNo;
}
return item.body.length > 80 ? `${item.body.slice(0, 80)}` : item.body;
}
function openDetail(id: string) {
router.push(`/messages/${id}`);
}
async function fetchPage(nextPage: number, append = false) {
const result = await loadMessages(nextPage, append);
if (result) {
page.value = result.page;
total.value = result.total;
}
}
function tryLoad() {
if (!auth.token) return;
void fetchPage(1);
void refreshUnreadCount();
}
function goLogin() {
auth.showLoginPrompt('/messages');
}
function onDelete(id: string, event: Event) {
event.stopPropagation();
if (deletingId.value) return;
pendingDeleteId.value = id;
deleteConfirmVisible.value = true;
}
function onDeleteCancel() {
pendingDeleteId.value = null;
}
async function confirmDelete() {
const id = pendingDeleteId.value;
if (!id || deletingId.value) return;
deletingId.value = id;
try {
await deleteMessage(id);
total.value = Math.max(0, total.value - 1);
deleteConfirmVisible.value = false;
pendingDeleteId.value = null;
} finally {
deletingId.value = null;
}
}
onMounted(tryLoad);
onActivated(tryLoad);
</script>
<template>
<div class="message-list-panel">
<div v-if="!auth.token" class="guest-hint">
<p>{{ t('auth.login_required') }}</p>
<button type="button" class="login-link" @click="goLogin">{{ t('auth.go_login') }}</button>
</div>
<template v-else>
<div v-if="loading && !listLoaded" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="!messages.length" class="empty">
<p>{{ t('messages.empty') }}</p>
</div>
<div v-else class="list">
<div
v-for="item in messages"
:key="item.id"
class="list-row"
:class="{ unread: !item.isRead }"
>
<button type="button" class="row-body" @click="openDetail(item.id)">
<span class="row-dot" aria-hidden="true" />
<span class="row-main">
<span class="title-row">
<span class="title">{{ messageTitle(item) }}</span>
<span class="status-badge" :class="{ unread: !item.isRead }">
{{ item.isRead ? t('messages.status_read') : t('messages.status_unread') }}
</span>
</span>
<span class="preview">{{ messagePreview(item) }}</span>
<span v-if="item.createdAt" class="date">{{ formatDate(item.createdAt) }}</span>
</span>
<span class="chevron" aria-hidden="true"></span>
</button>
<button
type="button"
class="delete-btn"
:aria-label="t('messages.delete')"
:disabled="deletingId === item.id"
@click="onDelete(item.id, $event)"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M6 7h12M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7h12Z"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<button v-if="hasMore" type="button" class="load-more" :disabled="loading" @click="fetchPage(page + 1, true)">
{{ loading ? t('common.loading_more') : t('messages.load_more') }}
</button>
</div>
</template>
<ConfirmDialog
v-model:visible="deleteConfirmVisible"
:title="t('messages.delete')"
:message="t('messages.delete_confirm')"
:confirm-text="t('messages.delete')"
danger
:loading="!!deletingId"
@confirm="confirmDelete"
@cancel="onDeleteCancel"
/>
</div>
</template>
<style scoped>
.message-list-panel {
flex: 1;
min-height: 0;
}
.guest-hint,
.state,
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
color: var(--text-muted);
}
.login-link {
border: 1px solid var(--border-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 13px;
font-weight: 600;
}
.list {
display: flex;
flex-direction: column;
}
.list-row {
display: flex;
align-items: stretch;
gap: 0;
border-bottom: 1px solid var(--border);
}
.row-body {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
padding: 14px 0;
border: none;
background: none;
text-align: left;
cursor: pointer;
}
.list-row.unread .title {
color: var(--primary-dark, #002847);
font-weight: 700;
}
.row-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: transparent;
flex-shrink: 0;
}
.list-row.unread .row-dot {
background: var(--primary);
box-shadow: 0 0 6px rgba(0, 61, 107, 0.35);
}
.row-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.title {
flex: 1;
min-width: 0;
font-size: 15px;
color: #374151;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-badge {
flex-shrink: 0;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
background: rgba(0, 0, 0, 0.05);
}
.status-badge.unread {
color: var(--primary);
background: rgba(0, 61, 107, 0.08);
}
.preview {
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
font-size: 12px;
color: var(--text-muted);
}
.chevron {
color: var(--text-muted);
font-size: 20px;
flex-shrink: 0;
}
.delete-btn {
flex-shrink: 0;
align-self: center;
width: 36px;
height: 36px;
margin-left: 4px;
border: none;
border-radius: 8px;
background: transparent;
color: #9ca3af;
cursor: pointer;
}
.delete-btn:active:not(:disabled) {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}
.delete-btn svg {
width: 18px;
height: 18px;
}
.delete-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.load-more {
margin-top: 12px;
width: 100%;
padding: 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
color: var(--primary);
font-size: 13px;
}
</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

@@ -139,14 +139,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 +164,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 +174,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 +187,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

@@ -76,11 +76,12 @@ const stats = computed(() => {
<style scoped>
.wallet-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: 12px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.stats-row {
@@ -99,7 +100,7 @@ const stats = computed(() => {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
color: #6B7280;
letter-spacing: 0.03em;
margin-bottom: 2px;
}
@@ -112,15 +113,16 @@ const stats = computed(() => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #1A1A2E;
}
.stat-val.income { color: #3db865; }
.stat-val.expense { color: #e05050; }
.stat-val.cashback { color: #f0b90b; }
.stat-val.income { color: #059669; }
.stat-val.expense { color: #DC2626; }
.stat-val.cashback { color: #F8971F; }
.stats-divider {
height: 1px;
margin: 6px 8px;
background: linear-gradient(90deg, transparent, var(--border), transparent);
background: #E5E7EB;
}
</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

@@ -75,6 +75,8 @@ function formatOdds(odds: string) {
<style scoped>
.cs-panel {
padding: 6px 8px 0;
background: #EDF0F4;
border-top: 1px solid #D1D5DB;
}
.cs-panel--locked {
@@ -93,7 +95,7 @@ function formatOdds(odds: string) {
text-align: center;
font-size: 10px;
font-weight: 700;
color: var(--text-muted);
color: #4B5563;
}
.cols-grid {
@@ -116,36 +118,38 @@ 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;
}
.score-card.selected {
border-color: var(--primary);
background: rgba(212, 175, 55, 0.1);
border-color: #003D6B;
border-width: 2px;
background: rgba(0, 61, 107, 0.08);
}
.score-card--locked {
background: #111;
border-color: #333;
background: #E8EDF2;
border-color: #E5E7EB;
}
.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: #E8870A;
}
</style>

View File

@@ -57,7 +57,8 @@ function onPick(id: string) {
<style scoped>
.wrap {
padding: 6px 8px 8px;
background: #0c0c0c;
background: #EDF0F4;
border-top: 1px solid #D1D5DB;
}
.wrap.locked {
@@ -78,37 +79,40 @@ function onPick(id: string) {
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;
}
.odds-btn.selected {
border-color: var(--primary);
background: rgba(212, 175, 55, 0.1);
border-color: #003D6B;
border-width: 2px;
background: rgba(0, 61, 107, 0.08);
}
.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: #E8870A;
}
</style>

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n';
defineProps<{
label: string;
promoLabel?: string;
statusLabel?: string;
expanded: boolean;
hasMarket: boolean;
}>();
@@ -21,9 +22,9 @@ const { t } = useI18n();
@click="emit('toggle')"
>
<span class="row-label">{{ label }}</span>
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
<span v-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
<span v-else class="row-chevron" :class="{ open: expanded }" aria-hidden="true"></span>
<span v-if="promoLabel" class="row-promo">{{ promoLabel }}</span>
<span v-if="statusLabel" class="row-status">{{ statusLabel }}</span>
<span v-else-if="!hasMarket" class="row-muted">{{ t('bet.market_closed') }}</span>
</button>
</template>
@@ -44,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 {
@@ -57,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 {
@@ -71,7 +72,7 @@ const { t } = useI18n();
min-width: 0;
font-size: 13px;
font-weight: 700;
color: var(--text);
color: #1A1A2E;
letter-spacing: 0.02em;
}
@@ -79,32 +80,31 @@ 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.expanded .row-label {
color: var(--primary-light);
color: #003D6B;
}
.row-muted {
font-size: 10px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
}
.row-chevron {
font-size: 11px;
color: var(--text-muted);
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s;
flex-shrink: 0;
}
.row-chevron.open {
transform: rotate(90deg);
color: var(--primary-light);
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { ref, computed, watch, onUnmounted } from 'vue';
import { useI18n } from 'vue-i18n';
import api from '../../api';
import { usePlayerProfile } from '../../composables/usePlayerProfile';
@@ -16,6 +16,23 @@ export interface OutrightPick {
eventTitle: string;
}
interface SelectionOddsRow {
id: string;
odds: string;
oddsVersion: string;
status: string;
marketStatus: string;
marketShowOnPlayer: boolean;
matchStatus: string;
}
type OddsDelta = {
oldOdds: number;
newOdds: number;
newVersion: string;
suspended: boolean;
};
const props = defineProps<{
open: boolean;
pick: OutrightPick | null;
@@ -34,6 +51,11 @@ const balance = ref(0);
const successBalance = ref(0);
const successStake = ref(0);
const showSuccess = ref(false);
const currentOdds = ref('');
const currentOddsVersion = ref('');
const oddsDelta = ref<OddsDelta | null>(null);
const ODDS_POLL_MS = 5000;
let oddsPollTimer: ReturnType<typeof setInterval> | null = null;
const flagUrl = computed(() =>
props.pick ? teamFlagUrl(props.pick.teamCode, props.pick.teamName) : null,
@@ -41,12 +63,14 @@ const flagUrl = computed(() =>
const balanceText = computed(() => formatMoney(balance.value, locale.value));
const oddsNum = computed(() => {
if (!props.pick) return 0;
const n = parseFloat(props.pick.odds);
const effectiveOdds = computed(() => {
if (oddsDelta.value && !oddsDelta.value.suspended) return oddsDelta.value.newOdds;
const n = parseFloat(currentOdds.value);
return Number.isFinite(n) ? n : 0;
});
const oddsNum = computed(() => effectiveOdds.value);
const estReturn = computed(() => {
const s = Number(stake.value);
if (!s || s <= 0 || !oddsNum.value) return 0;
@@ -55,22 +79,120 @@ const estReturn = computed(() => {
const estReturnText = computed(() => formatMoney(estReturn.value, locale.value));
const hasPendingOddsChanges = computed(() => Boolean(oddsDelta.value && !oddsDelta.value.suspended));
const hasSuspendedSelection = computed(() => Boolean(oddsDelta.value?.suspended));
const oddsWarningText = computed(() => {
if (hasSuspendedSelection.value) return t('bet.odds_suspended');
if (hasPendingOddsChanges.value) return t('bet.odds_changed');
return '';
});
const submitButtonLabel = computed(() => {
if (loading.value) return t('bet.placing');
if (hasPendingOddsChanges.value) return t('bet.accept_changes_place');
return t('bet.place_bet_short');
});
function syncPickState() {
if (!props.pick) return;
currentOdds.value = props.pick.odds;
currentOddsVersion.value = props.pick.oddsVersion;
oddsDelta.value = null;
}
function acceptPendingOdds() {
if (!oddsDelta.value || oddsDelta.value.suspended) return;
currentOdds.value = oddsDelta.value.newOdds.toFixed(2);
currentOddsVersion.value = oddsDelta.value.newVersion;
oddsDelta.value = null;
}
function stopOddsPolling() {
if (oddsPollTimer) {
clearInterval(oddsPollTimer);
oddsPollTimer = null;
}
}
async function pollSelectionOdds() {
if (!props.pick || !props.open || step.value !== 'form') return;
try {
const { data } = await api.get('/player/selections/odds', {
params: { ids: props.pick.selectionId },
});
const row: SelectionOddsRow | undefined = data.data?.items?.[0];
if (!row) return;
const suspended =
row.status !== 'OPEN' ||
row.marketStatus !== 'OPEN' ||
row.marketShowOnPlayer === false ||
row.matchStatus !== 'PUBLISHED';
const newOdds = parseFloat(row.odds);
const versionChanged = row.oddsVersion !== currentOddsVersion.value;
const baseOdds = parseFloat(currentOdds.value);
const oddsChanged = Number.isFinite(newOdds) && Math.abs(newOdds - baseOdds) > 0.0001;
if (suspended || versionChanged || oddsChanged) {
oddsDelta.value = {
oldOdds: oddsDelta.value?.oldOdds ?? baseOdds,
newOdds: Number.isFinite(newOdds) ? newOdds : baseOdds,
newVersion: row.oddsVersion,
suspended,
};
} else {
oddsDelta.value = null;
}
} catch {
/* silent retry on next tick */
}
}
function startOddsPolling() {
stopOddsPolling();
void pollSelectionOdds();
oddsPollTimer = setInterval(() => {
void pollSelectionOdds();
}, ODDS_POLL_MS);
}
watch(
() => props.open,
async (v) => {
if (!v) return;
if (!v) {
stopOddsPolling();
oddsDelta.value = null;
return;
}
step.value = 'form';
stake.value = 1;
error.value = '';
syncPickState();
try {
const { data } = await api.get('/player/profile');
balance.value = parseAmount(data.data?.wallet?.availableBalance);
} catch {
balance.value = 0;
}
startOddsPolling();
},
);
watch(
() => props.pick?.selectionId,
() => {
if (!props.open) return;
syncPickState();
if (props.open) void pollSelectionOdds();
},
);
onUnmounted(() => {
stopOddsPolling();
});
function close() {
emit('close');
}
@@ -93,12 +215,20 @@ async function submit() {
error.value = t('bet.outright_insufficient');
return;
}
if (hasSuspendedSelection.value) {
error.value = t('bet.odds_suspended');
return;
}
if (hasPendingOddsChanges.value) {
acceptPendingOdds();
}
loading.value = true;
error.value = '';
try {
await api.post('/player/bets/single', {
selectionId: props.pick.selectionId,
oddsVersion: props.pick.oddsVersion,
oddsVersion: currentOddsVersion.value,
stake: stake.value,
requestId: genRequestId(),
});
@@ -109,9 +239,13 @@ async function submit() {
showSuccess.value = true;
void refreshProfile();
} catch (e: unknown) {
error.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
t('bet.outright_bet_failed');
const err = e as { response?: { data?: { error?: string; code?: string } } };
if (err.response?.data?.code === 'ODDS_CHANGED') {
await pollSelectionOdds();
error.value = t('bet.odds_changed');
return;
}
error.value = err.response?.data?.error || t('bet.outright_bet_failed');
} finally {
loading.value = false;
}
@@ -134,11 +268,27 @@ function formatOdds(odds: string) {
<div class="hero">
<img v-if="flagUrl" :src="flagUrl" alt="" class="flag" />
<p class="team">{{ pick.teamName }}</p>
<span class="odds-badge">@ {{ formatOdds(pick.odds) }}</span>
<span
class="odds-badge"
:class="{
'odds-badge--up': oddsDelta && !oddsDelta.suspended && oddsDelta.newOdds >= oddsDelta.oldOdds,
'odds-badge--down': oddsDelta && !oddsDelta.suspended && oddsDelta.newOdds < oddsDelta.oldOdds,
'odds-badge--suspended': oddsDelta?.suspended,
}"
>
<template v-if="oddsDelta && !oddsDelta.suspended">
@ {{ oddsDelta.oldOdds.toFixed(2) }} {{ oddsDelta.newOdds.toFixed(2) }}
</template>
<template v-else>
@ {{ formatOdds(currentOdds || pick.odds) }}
</template>
</span>
</div>
<p class="event-title">{{ pick.eventTitle }}</p>
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<div class="balance-row">
<span class="balance-label">{{ t('bet.outright_balance') }}</span>
<span class="balance-value">{{ balanceText }}</span>
@@ -175,10 +325,10 @@ function formatOdds(odds: string) {
<button
type="button"
class="btn-confirm btn-gold-outline"
:disabled="loading || stake <= 0"
:disabled="loading || stake <= 0 || hasSuspendedSelection"
@click="submit"
>
{{ loading ? t('bet.placing') : t('bet.place_bet_short') }}
{{ submitButtonLabel }}
</button>
</div>
</template>
@@ -221,7 +371,7 @@ function formatOdds(odds: string) {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.72);
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
@@ -233,12 +383,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);
border-radius: var(--radius);
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 18px 16px 16px;
text-align: center;
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 {
@@ -248,8 +398,8 @@ function formatOdds(odds: string) {
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;
line-height: 1;
}
@@ -257,7 +407,7 @@ function formatOdds(odds: string) {
.hint {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
color: #6B7280;
margin-bottom: 12px;
letter-spacing: 0.04em;
}
@@ -280,30 +430,56 @@ function formatOdds(odds: string) {
height: 32px;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.team {
font-size: 20px;
font-weight: 900;
color: var(--primary-light);
color: #1A1A2E;
letter-spacing: 0.02em;
}
.odds-badge {
display: inline-block;
padding: 3px 10px;
background: rgba(198, 40, 40, 0.85);
border: 1px solid rgba(255, 120, 120, 0.35);
color: #fff;
background: #F8971F;
border: 1px solid rgba(248, 151, 31, 0.3);
color: #FFFFFF;
font-size: 13px;
font-weight: 800;
border-radius: 4px;
}
.odds-badge--up {
background: rgba(22, 101, 52, 0.9);
border-color: rgba(110, 231, 160, 0.35);
}
.odds-badge--down {
background: rgba(153, 27, 27, 0.92);
border-color: rgba(255, 139, 139, 0.4);
}
.odds-badge--suspended {
background: rgba(120, 83, 14, 0.9);
border-color: rgba(255, 184, 77, 0.35);
}
.odds-warning {
margin: 0 0 10px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(244, 162, 97, 0.12);
border: 1px solid rgba(244, 162, 97, 0.28);
color: var(--primary-light);
font-size: 12px;
line-height: 1.45;
}
.event-title {
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
margin-bottom: 12px;
line-height: 1.4;
padding: 0 4px;
@@ -315,28 +491,28 @@ function formatOdds(odds: string) {
align-items: center;
padding: 8px 10px;
margin-bottom: 12px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border);
border-radius: 6px;
background: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 8px;
}
.balance-label {
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
}
.balance-value {
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
color: #003D6B;
}
.stake-label {
display: block;
text-align: left;
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
margin-bottom: 6px;
}
@@ -345,10 +521,10 @@ function formatOdds(odds: string) {
width: 100%;
padding: 11px 12px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid var(--border);
background: #0a0a0a;
color: var(--text);
border-radius: 8px;
border: 1px solid #E5E7EB;
background: #F5F7FA;
color: #1A1A2E;
font-size: 18px;
font-weight: 800;
text-align: center;
@@ -356,12 +532,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: #003D6B;
box-shadow: 0 0 0 1px rgba(0, 61, 107, 0.15);
}
.stake-input::placeholder {
color: #555;
color: #6B7280;
font-weight: 500;
font-size: 14px;
}
@@ -375,33 +551,34 @@ function formatOdds(odds: string) {
.chip {
flex: 1;
padding: 6px 4px;
border-radius: 6px;
border: 1px solid var(--border);
background: #141414;
color: var(--text-muted);
border-radius: 8px;
border: 1px solid #E5E7EB;
background: #FFFFFF;
color: #6B7280;
font-size: 11px;
font-weight: 700;
transition: border-color 0.15s ease, background 0.15s ease;
}
.chip:active {
border-color: var(--border-gold-soft);
color: var(--primary-light);
background: rgba(212, 175, 55, 0.08);
border-color: #003D6B;
color: #003D6B;
background: rgba(0, 61, 107, 0.06);
}
.est-return {
font-size: 12px;
color: var(--text-muted);
color: #6B7280;
margin-bottom: 12px;
}
.est-value {
color: var(--primary-light);
color: #003D6B;
font-weight: 800;
}
.error {
color: var(--danger);
color: #DC2626;
font-size: 12px;
font-weight: 600;
margin-bottom: 10px;
@@ -415,19 +592,22 @@ function formatOdds(odds: string) {
.btn-cancel {
padding: 10px;
border-radius: 6px;
background: #1a1a1a;
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 8px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
color: #6B7280;
font-size: 14px;
font-weight: 700;
}
.btn-confirm {
padding: 10px;
border-radius: 6px;
border-radius: 8px;
font-size: 14px;
font-weight: 800;
background: #003D6B;
color: #FFFFFF;
border: none;
}
.btn-confirm:disabled {
@@ -439,9 +619,9 @@ function formatOdds(odds: string) {
height: 44px;
margin: 0 auto 8px;
border-radius: 50%;
border: 2px solid rgba(46, 158, 94, 0.8);
background: rgba(46, 158, 94, 0.12);
color: #4ade80;
border: 2px solid #059669;
background: rgba(5, 150, 105, 0.1);
color: #059669;
font-size: 24px;
font-weight: 900;
line-height: 40px;
@@ -450,37 +630,40 @@ function formatOdds(odds: string) {
.success-title {
font-size: 16px;
font-weight: 800;
color: var(--primary-light);
color: #1A1A2E;
margin-bottom: 10px;
}
.summary {
margin: 12px 0 14px;
padding: 10px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--border);
border-radius: 6px;
background: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 8px;
}
.summary-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--text-muted);
color: #6B7280;
padding: 4px 0;
}
.summary-row + .summary-row {
margin-top: 4px;
padding-top: 8px;
border-top: 1px solid var(--border);
border-top: 1px solid #E5E7EB;
}
.btn-done {
width: 100%;
padding: 11px;
border-radius: 6px;
border-radius: 8px;
font-size: 14px;
font-weight: 800;
background: #003D6B;
color: #FFFFFF;
border: none;
}
</style>

View File

@@ -86,10 +86,11 @@ const headMeta = computed(() => {
position: relative;
isolation: isolate;
margin-bottom: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-card);
border: 1px solid #E5E7EB;
border-radius: 8px;
background: #FFFFFF;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.event-head {
@@ -112,7 +113,7 @@ const headMeta = computed(() => {
position: absolute;
inset: 0;
background: v-bind(matchCardBg) center / 100% 100% no-repeat;
opacity: 0.25;
opacity: 0.08;
z-index: -1;
pointer-events: none;
}
@@ -122,8 +123,8 @@ const headMeta = computed(() => {
width: 26px;
height: 26px;
border-radius: 50%;
background: #141414;
border: 1px solid var(--border-gold-soft);
background: #F5F7FA;
border: 1px solid #E5E7EB;
display: flex;
align-items: center;
justify-content: center;
@@ -131,11 +132,11 @@ const headMeta = computed(() => {
}
.toggle-icon.open {
border-color: var(--primary-light);
border-color: #003D6B;
}
.toggle-mark {
color: var(--primary-light);
color: #003D6B;
font-size: 17px;
font-weight: 900;
line-height: 1;
@@ -152,21 +153,21 @@ const headMeta = computed(() => {
.event-title {
font-size: 13px;
font-weight: 800;
color: var(--primary-light);
color: #003D6B;
line-height: 1.35;
}
.event-league {
font-size: 11px;
font-weight: 600;
color: #888;
color: #6B7280;
line-height: 1.3;
}
.event-meta {
font-size: 11px;
font-weight: 600;
color: #666;
color: #6B7280;
}
.event-saishi {
@@ -176,7 +177,7 @@ const headMeta = computed(() => {
max-width: 40px;
object-fit: contain;
padding-left: 8px;
border-left: 1px solid #2a2a2a;
border-left: 1px solid #E5E7EB;
}
.options-scroll {
@@ -185,7 +186,7 @@ const headMeta = computed(() => {
-webkit-overflow-scrolling: touch;
padding: 8px 2px 2px;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
scrollbar-color: #E5E7EB transparent;
}
.options-grid {

View File

@@ -76,27 +76,21 @@ onUnmounted(() => {
width: 100%;
min-width: 0;
padding: 8px 4px 6px;
border-radius: 6px;
background: linear-gradient(180deg, #1a1a1a 0%, #0d0d0d 100%);
border: 1px solid rgba(140, 140, 140, 0.35);
border-radius: 8px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
text-align: center;
overflow: hidden;
}
.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;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.option-card:active {
border-color: var(--border-gold-soft);
border-color: #003D6B;
box-shadow: 0 0 0 1px rgba(0, 61, 107, 0.12);
}
.flag {
@@ -119,7 +113,7 @@ onUnmounted(() => {
.name {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
color: #1A1A2E;
line-height: 1.2;
width: 100%;
overflow: hidden;
@@ -130,6 +124,6 @@ onUnmounted(() => {
.odds {
font-size: 11px;
font-weight: 800;
color: var(--primary-light);
color: #F8971F;
}
</style>

View File

@@ -178,7 +178,7 @@ function closeModal() {
padding: 0 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
color: #6B7280;
line-height: 1.4;
}
@@ -189,7 +189,7 @@ function closeModal() {
.state,
.empty {
text-align: center;
color: var(--text-muted);
color: #6B7280;
padding: 48px 20px;
font-weight: 600;
}

View File

@@ -0,0 +1,174 @@
import api from '../api';
import { usePlayerProfile } from './usePlayerProfile';
import { usePlayerMessages } from './usePlayerMessages';
interface DepositOrderRow {
id: string;
orderNo: string;
amount: string;
status: string;
rejectReason?: string | null;
}
const POLL_FAST_MS = 8_000;
const POLL_SLOW_MS = 30_000;
const TRACKED_STORAGE_KEY = 'player_deposit_tracked_pending';
const lastStatus = new Map<string, string>();
const trackedPending = new Set<string>();
const notifiedKeys = new Set<string>();
let pollTimer: ReturnType<typeof setInterval> | null = null;
let pollingActive = false;
function notifyKey(orderId: string, type: 'approved' | 'rejected') {
return `${orderId}:${type}`;
}
function loadTrackedFromStorage() {
try {
const raw = sessionStorage.getItem(TRACKED_STORAGE_KEY);
if (!raw) return;
const ids: string[] = JSON.parse(raw);
for (const id of ids) {
if (id) trackedPending.add(String(id));
}
} catch {
/* ignore */
}
}
function persistTrackedToStorage() {
try {
sessionStorage.setItem(TRACKED_STORAGE_KEY, JSON.stringify([...trackedPending]));
} catch {
/* ignore */
}
}
function addTracked(orderId: string) {
trackedPending.add(orderId);
persistTrackedToStorage();
}
function removeTracked(orderId: string) {
if (!trackedPending.delete(orderId)) return;
persistTrackedToStorage();
}
function hasPendingInterest() {
return trackedPending.size > 0;
}
function schedulePoll(intervalMs: number) {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(() => {
void pollOnce();
}, intervalMs);
}
function adjustPollInterval() {
if (!pollingActive) return;
schedulePoll(hasPendingInterest() ? POLL_FAST_MS : POLL_SLOW_MS);
}
function trackPendingOrder(orderId: string) {
const id = String(orderId);
if (!id) return;
addTracked(id);
lastStatus.set(id, 'PENDING');
adjustPollInterval();
void pollOnce();
}
function shouldNotify(orderId: string, prev: string | undefined, next: string) {
if (next !== 'APPROVED' && next !== 'REJECTED') return false;
if (prev === 'PENDING') return true;
if (trackedPending.has(orderId)) return true;
return false;
}
function handleStatusChange(order: DepositOrderRow): boolean {
const prev = lastStatus.get(order.id);
const next = order.status;
lastStatus.set(order.id, next);
if (next === 'PENDING') {
addTracked(order.id);
return false;
}
removeTracked(order.id);
if (!shouldNotify(order.id, prev, next)) return false;
const type = next === 'APPROVED' ? 'approved' : 'rejected';
const key = notifyKey(order.id, type);
if (notifiedKeys.has(key)) return false;
notifiedKeys.add(key);
void usePlayerMessages().refreshUnreadCount();
return next === 'APPROVED';
}
async function pollOnce() {
try {
const { data } = await api.get('/player/deposit-orders', { params: { page: 1 } });
const items: DepositOrderRow[] = data.data?.items ?? [];
const { refreshProfile } = usePlayerProfile();
let needsProfileRefresh = false;
for (const order of items) {
if (handleStatusChange(order)) needsProfileRefresh = true;
}
if (needsProfileRefresh) await refreshProfile();
adjustPollInterval();
} catch {
/* silent retry on next tick */
}
}
function onVisibilityChange() {
if (!document.hidden && pollingActive) void pollOnce();
}
function startPolling() {
if (pollingActive) {
void pollOnce();
return;
}
pollingActive = true;
loadTrackedFromStorage();
for (const id of trackedPending) {
if (!lastStatus.has(id)) lastStatus.set(id, 'PENDING');
}
document.addEventListener('visibilitychange', onVisibilityChange);
void pollOnce();
schedulePoll(hasPendingInterest() ? POLL_FAST_MS : POLL_SLOW_MS);
}
function stopPolling() {
pollingActive = false;
trackedPending.clear();
lastStatus.clear();
notifiedKeys.clear();
try {
sessionStorage.removeItem(TRACKED_STORAGE_KEY);
} catch {
/* ignore */
}
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
}
export function useDepositNotifications() {
return {
trackPendingOrder,
startPolling,
stopPolling,
pollOnce,
};
}

View File

@@ -0,0 +1,23 @@
import { computed } from 'vue';
import { usePlayerHome } from './usePlayerHome';
/** 玩家端站内邮箱功能开关(来自 /player/home */
export function useInboxFeature() {
const { homeRaw } = usePlayerHome();
const inboxEnabled = computed(() => homeRaw.value?.inboxEnabled !== false);
const hubRoute = computed(() =>
inboxEnabled.value ? '/messages' : '/messages?tab=support',
);
const hubOpenLabelKey = computed(() =>
inboxEnabled.value ? 'inbox_hub.open' : 'inbox_hub.open_support',
);
const hubTitleKey = computed(() =>
inboxEnabled.value ? 'inbox_hub.title' : 'inbox_hub.tab_support',
);
return { inboxEnabled, hubRoute, hubOpenLabelKey, hubTitleKey };
}

View File

@@ -4,6 +4,7 @@ import api from '../api';
import type { BannerItem } from '../components/BannerCarousel.vue';
import { resolveBanners } from '../constants/defaultBanner';
import { resolveAnnouncements } from '../constants/defaultAnnouncement';
import { stripHtml } from '../utils/html';
export interface PlayerHomeMatch {
id: string;
@@ -20,12 +21,52 @@ export interface PlayerHomeMatch {
displayOrder?: number;
}
export interface PlayerContentItem {
id: string;
contentType?: string;
sortOrder?: number;
createdAt?: string;
linkType?: string | null;
linkTarget?: string | null;
translation?: {
title?: string | null;
body?: string | null;
imageUrl?: string | null;
};
}
export type PlayerAnnouncementItem = PlayerContentItem;
interface HomePayload {
banners?: BannerItem[];
announcements?: Array<{ translation?: { title?: string; body?: string } }>;
ticker?: Array<{ translation?: { title?: string; body?: string } }>;
notices?: Array<{ translation?: { title?: string; body?: string } }>;
banners?: PlayerContentItem[];
announcements?: PlayerAnnouncementItem[];
ticker?: PlayerAnnouncementItem[];
notices?: PlayerAnnouncementItem[];
hotMatches?: PlayerHomeMatch[];
upcomingMatches?: PlayerHomeMatch[];
inboxEnabled?: boolean;
}
function mergeMatchList(
existing: PlayerHomeMatch[] | undefined,
fresh: PlayerHomeMatch[] | undefined,
): PlayerHomeMatch[] | undefined {
if (!fresh) return existing;
if (!existing) return fresh;
const freshMap = new Map(fresh.map((m) => [m.id, m]));
for (const m of existing) {
const f = freshMap.get(m.id);
if (f) Object.assign(m, f);
}
const existingIds = new Set(existing.map((m) => m.id));
for (const fm of fresh) {
if (!existingIds.has(fm.id)) existing.push(fm);
}
for (let i = existing.length - 1; i >= 0; i--) {
if (!freshMap.has(existing[i].id)) existing.splice(i, 1);
}
return existing;
}
const homeRaw = ref<HomePayload | null>(null);
@@ -40,12 +81,22 @@ function collectAnnouncementLines(data: HomePayload | null): string[] {
const lines: string[] = [];
for (const item of source) {
const text = item.translation?.title || item.translation?.body;
const title = item.translation?.title?.trim();
const text = title || stripHtml(item.translation?.body ?? '');
if (text) lines.push(text);
}
return lines;
}
function collectAnnouncementItems(data: HomePayload | null): PlayerAnnouncementItem[] {
if (!data) return [];
const source =
data.announcements && data.announcements.length > 0
? data.announcements
: [...(data.ticker ?? []), ...(data.notices ?? [])];
return source.filter((item) => item.translation?.title || item.translation?.body);
}
/** 管理端公共内容 → 玩家端首页/跑马灯(单例,避免重复请求) */
export function usePlayerHome() {
const { t } = useI18n();
@@ -64,26 +115,10 @@ export function usePlayerHome() {
existing.announcements = fresh.announcements;
existing.ticker = fresh.ticker;
existing.notices = fresh.notices;
existing.inboxEnabled = fresh.inboxEnabled;
if (fresh.hotMatches && existing.hotMatches) {
const freshMap = new Map(fresh.hotMatches.map((m) => [m.id, m]));
for (const m of existing.hotMatches) {
const f = freshMap.get(m.id);
if (f) Object.assign(m, f);
}
// 处理新增或删除的比赛
const existingIds = new Set(existing.hotMatches.map((m) => m.id));
for (const fm of fresh.hotMatches) {
if (!existingIds.has(fm.id)) existing.hotMatches.push(fm);
}
for (let i = existing.hotMatches.length - 1; i >= 0; i--) {
if (!freshMap.has(existing.hotMatches[i].id)) {
existing.hotMatches.splice(i, 1);
}
}
} else {
existing.hotMatches = fresh.hotMatches;
}
existing.hotMatches = mergeMatchList(existing.hotMatches, fresh.hotMatches);
existing.upcomingMatches = mergeMatchList(existing.upcomingMatches, fresh.upcomingMatches);
} else {
homeRaw.value = fresh;
}
@@ -94,18 +129,24 @@ export function usePlayerHome() {
}
}
const banners = computed(() => resolveBanners(homeRaw.value?.banners));
const banners = computed(() => resolveBanners(homeRaw.value?.banners as BannerItem[] | undefined));
const bannerItems = computed(() => homeRaw.value?.banners ?? []);
const announcements = computed(() =>
resolveAnnouncements(collectAnnouncementLines(homeRaw.value), t('home.announcement_default')),
);
const announcementItems = computed(() => collectAnnouncementItems(homeRaw.value));
const hotMatches = computed(() => homeRaw.value?.hotMatches ?? []);
const upcomingMatches = computed(() => homeRaw.value?.upcomingMatches ?? []);
return {
homeRaw,
loading,
load,
banners,
bannerItems,
announcements,
announcementItems,
hotMatches,
upcomingMatches,
};
}

View File

@@ -0,0 +1,130 @@
import { ref } from 'vue';
import api from '../api';
export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO';
export type DepositMessagePayload = {
depositOrderId?: string;
orderNo?: string;
amount?: string;
approvedAmount?: string | null;
rejectReason?: string | null;
};
export type BannerPromoPayload = {
contentId?: string;
};
export interface PlayerMessage {
id: string;
type: PlayerMessageType | string;
title: string;
body: string;
payload: DepositMessagePayload | BannerPromoPayload | null;
readAt: string | null;
createdAt: string;
isRead: boolean;
}
interface MessageListResponse {
items: PlayerMessage[];
total: number;
unreadCount: number;
page: number;
pageSize: number;
}
const unreadCount = ref(0);
const messages = ref<PlayerMessage[]>([]);
const loading = ref(false);
const listLoaded = ref(false);
async function refreshUnreadCount() {
try {
const { data } = await api.get('/player/messages/unread-count');
unreadCount.value = Number(data.data?.unreadCount ?? 0);
} catch {
/* silent */
}
}
async function loadMessages(page = 1, append = false) {
loading.value = true;
try {
const { data } = await api.get('/player/messages', { params: { page, pageSize: 20 } });
const payload = data.data as MessageListResponse;
const items = payload?.items ?? [];
messages.value = append ? [...messages.value, ...items] : items;
unreadCount.value = Number(payload?.unreadCount ?? unreadCount.value);
listLoaded.value = true;
return payload;
} finally {
loading.value = false;
}
}
async function loadMessageDetail(id: string) {
const { data } = await api.get(`/player/messages/${id}`);
return data.data as PlayerMessage;
}
async function markMessageRead(id: string) {
const { data } = await api.patch(`/player/messages/${id}/read`);
const updated = data.data as PlayerMessage;
messages.value = messages.value.map((item) =>
item.id === id ? { ...item, ...updated, isRead: true } : item,
);
if (unreadCount.value > 0) unreadCount.value -= 1;
return updated;
}
async function markAllRead() {
await api.patch('/player/messages/read-all');
messages.value = messages.value.map((item) => ({
...item,
isRead: true,
readAt: item.readAt ?? new Date().toISOString(),
}));
unreadCount.value = 0;
}
async function deleteMessage(id: string) {
const { data } = await api.delete(`/player/messages/${id}`);
const wasUnread = Boolean(data.data?.wasUnread);
messages.value = messages.value.filter((item) => item.id !== id);
if (wasUnread && unreadCount.value > 0) unreadCount.value -= 1;
}
async function deleteAllMessages() {
await api.delete('/player/messages');
messages.value = [];
unreadCount.value = 0;
listLoaded.value = true;
}
function resetMessagesState() {
unreadCount.value = 0;
messages.value = [];
listLoaded.value = false;
}
export function usePlayerMessages() {
return {
unreadCount,
messages,
loading,
listLoaded,
refreshUnreadCount,
loadMessages,
loadMessageDetail,
markMessageRead,
markAllRead,
deleteMessage,
deleteAllMessages,
resetMessagesState,
};
}

View File

@@ -0,0 +1,37 @@
import api from '../api';
const PING_INTERVAL_MS = 60_000;
let pingTimer: ReturnType<typeof setInterval> | null = null;
let active = false;
async function sendPing() {
if (document.visibilityState !== 'visible') return;
try {
await api.post('/player/presence/ping');
} catch {
/* ignore transient network errors */
}
}
function onVisibilityChange() {
if (!active) return;
if (document.visibilityState === 'visible') void sendPing();
}
export function startPresencePing() {
if (active) return;
active = true;
void sendPing();
pingTimer = setInterval(() => void sendPing(), PING_INTERVAL_MS);
document.addEventListener('visibilitychange', onVisibilityChange);
}
export function stopPresencePing() {
active = false;
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}
document.removeEventListener('visibilitychange', onVisibilityChange);
}

View File

@@ -7,8 +7,6 @@ const FALLBACK_BANNER_URL = '/uploads/banners/welcome.svg';
export const DEFAULT_BANNER: BannerItem = {
id: 'default',
linkType: 'ROUTE',
linkTarget: '/bet',
translation: {
title: '',
imageUrl: defaultBannerImg,

View File

@@ -8,11 +8,16 @@ export default {
load_failed: 'Failed to load',
retry: 'Retry',
back_to_top: 'Back to top',
cancel: 'Cancel',
confirm: 'Confirm',
},
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
home: {
hot_matches: 'Hot matches',
hot_tab: 'Hot',
upcoming_tab: 'Upcoming',
no_matches: 'No matches',
upcoming_empty: 'No matches kicking off in the next 3 days',
announcement_badge: 'Notice',
announcement_default:
'Welcome to TheBet365 · Football events are live · Bet responsibly',
@@ -20,6 +25,70 @@ 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',
detail_title: 'Announcement',
empty: 'No announcements yet',
not_found: 'This announcement is unavailable',
view_all: 'View all announcements',
type_notice: 'Notice',
type_ticker: 'Ticker',
type_banner: 'Banner',
related_link: 'Related link',
go_link: 'Go to page',
open_link: 'Open link',
back: 'Back',
},
search: {
placeholder: 'Search teams or leagues',
no_results: 'No matches found',
results_count: '{count} matches found',
hint: 'Enter a team or league to filter on the Bet page',
},
deposit_notify: {
approved_title: 'Deposit approved',
rejected_title: 'Deposit rejected',
view_history: 'View deposit history',
view_messages: 'Open inbox',
dismiss: 'Dismiss',
},
messages: {
title: 'Inbox',
detail_title: 'Message',
empty: 'No messages yet',
not_found: 'Message not found',
view_all: 'Back to inbox',
back: 'Back',
mark_all_read: 'Mark all read',
load_more: 'Load more',
delete: 'Delete',
delete_all: 'Delete all',
delete_confirm: 'Delete this message?',
delete_all_confirm: 'Delete all messages? This cannot be undone.',
banner_promo_view: 'View promotion',
content_promo_view: 'View details',
status_unread: 'Unread',
status_read: 'Read',
deposit_approved_title: 'Deposit approved',
deposit_rejected_title: 'Deposit rejected',
deposit_approved_body: 'Order {orderNo} approved. Requested {amount}, credited {approvedAmount}.',
deposit_rejected_body: 'Order {orderNo} ({amount}) was rejected. {reason}',
reject_reason: 'Rejection reason',
no_reason: 'No reason provided',
view_recharge_history: 'View recharge history',
unread_badge: '{count} unread',
open_inbox: 'Open inbox',
},
inbox_hub: {
title: 'Messages & Support',
tab_messages: 'Inbox',
tab_support: 'Support',
open: 'Open messages and support',
open_support: 'Open support',
},
history: {
league_default: 'Football',
@@ -266,6 +335,13 @@ export default {
tab_outright: 'Outright',
tab_today: 'Today',
tab_early: 'Early',
filter_status_all: 'All',
filter_status_open: 'Open',
filter_status_settled: 'Settled',
filter_leagues_all: 'All Leagues',
status_open: 'Open',
status_live: 'Live',
match_unit: 'matches',
show_open_only: 'Open only',
show_all_matches: 'Show all',
today: 'Today',
@@ -297,6 +373,8 @@ export default {
parlay_block_quarter: 'Quarter-ball HDP/O-U cannot be parlayed',
parlay_block_not_allowed: 'This market cannot be parlayed',
parlay_need_more: 'Select at least 2 legs for parlay',
market_status_suspended: 'Suspended',
market_status_closed: 'Closed',
back: 'Back',
refresh: 'Refresh',
download: 'Download',
@@ -380,12 +458,18 @@ export default {
slip_tab_parlay: 'Parlay',
slip_parlay_empty_hint: 'Pick a selection, then tap Add to parlay',
slip_add_parlay: 'Add to parlay',
slip_parlay_only_hint: 'This market is parlay-only. Tap Add to parlay to bet.',
slip_parlay_same_match: 'Parlay selections cannot be from the same match. Remove the same-match leg first.',
slip_parlay_count: '{n} parlay leg(s)',
slip_total_stake: 'Total stake',
slip_currency: 'Amount',
slip_min: 'Min',
slip_min_error: 'Minimum stake is {amount}',
odds_changed: 'Odds have changed on some selections. Accept to continue.',
odds_suspended: 'Some selections are suspended or closed. Remove them to continue.',
accept_changes_place: 'Accept changes & place bet',
odds_was: 'Was',
odds_now: 'Now',
place_success: 'Bet placed',
place_failed: 'Bet failed',
},

View File

@@ -8,6 +8,8 @@ export default {
load_failed: 'Gagal dimuat',
retry: 'Cuba lagi',
back_to_top: 'Kembali ke atas',
cancel: 'Batal',
confirm: 'Sahkan',
},
nav: {
home: 'Laman Utama',
@@ -18,7 +20,10 @@ export default {
},
home: {
hot_matches: 'Perlawanan popular',
hot_tab: 'Popular',
upcoming_tab: 'Terdekat',
no_matches: 'Tiada perlawanan',
upcoming_empty: 'Tiada perlawanan dalam 3 hari akan datang',
announcement_badge: 'Notis',
announcement_default:
'Selamat datang ke TheBet365 · Perlawanan bola sepak sedang berlangsung · Bertaruh secara bertanggungjawab',
@@ -26,6 +31,70 @@ 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',
detail_title: 'Butiran Pengumuman',
empty: 'Tiada pengumuman',
not_found: 'Pengumuman tidak tersedia',
view_all: 'Lihat semua pengumuman',
type_notice: 'Notis',
type_ticker: 'Ticker',
type_banner: 'Banner',
related_link: 'Pautan berkaitan',
go_link: 'Pergi ke halaman',
open_link: 'Buka pautan',
back: 'Kembali',
},
search: {
placeholder: 'Cari pasukan atau liga',
no_results: 'Tiada perlawanan dijumpai',
results_count: '{count} perlawanan dijumpai',
hint: 'Masukkan pasukan atau liga untuk tapis di halaman Pertaruhan',
},
deposit_notify: {
approved_title: 'Deposit diluluskan',
rejected_title: 'Deposit ditolak',
view_history: 'Lihat sejarah deposit',
view_messages: 'Buka peti mesej',
dismiss: 'Tutup',
},
messages: {
title: 'Peti Mesej',
detail_title: 'Butiran Mesej',
empty: 'Tiada mesej',
not_found: 'Mesej tidak dijumpai',
view_all: 'Kembali ke peti mesej',
back: 'Kembali',
mark_all_read: 'Tanda semua dibaca',
load_more: 'Muat lagi',
delete: 'Padam',
delete_all: 'Padam semua',
delete_confirm: 'Padam mesej ini?',
delete_all_confirm: 'Padam semua mesej? Tindakan ini tidak boleh dibatalkan.',
banner_promo_view: 'Lihat promosi',
content_promo_view: 'Lihat butiran',
status_unread: 'Belum dibaca',
status_read: 'Dibaca',
deposit_approved_title: 'Deposit diluluskan',
deposit_rejected_title: 'Deposit ditolak',
deposit_approved_body: 'Pesanan {orderNo} diluluskan. Diminta {amount}, dikreditkan {approvedAmount}.',
deposit_rejected_body: 'Pesanan {orderNo} ({amount}) ditolak. {reason}',
reject_reason: 'Sebab penolakan',
no_reason: 'Tiada sebab diberikan',
view_recharge_history: 'Lihat sejarah deposit',
unread_badge: '{count} belum dibaca',
open_inbox: 'Buka peti mesej',
},
inbox_hub: {
title: 'Mesej & Sokongan',
tab_messages: 'Peti Mesej',
tab_support: 'Sokongan',
open: 'Buka mesej dan sokongan',
open_support: 'Buka sokongan',
},
history: {
league_default: 'Bola Sepak',
@@ -272,6 +341,13 @@ export default {
tab_outright: 'Juara',
tab_today: 'Hari Ini',
tab_early: 'Awal',
filter_status_all: 'Semua',
filter_status_open: 'Buka',
filter_status_settled: 'Selesai',
filter_leagues_all: 'Semua Liga',
status_open: 'Buka',
status_live: 'Sedang berlangsung',
match_unit: 'perlawanan',
show_open_only: 'Buka sahaja',
show_all_matches: 'Tunjuk semua',
today: 'Hari Ini',
@@ -303,6 +379,8 @@ export default {
parlay_block_quarter: 'HDP/O-U suku bola tidak boleh parlay',
parlay_block_not_allowed: 'Pasaran ini tidak boleh parlay',
parlay_need_more: 'Pilih sekurang-kurangnya 2 pilihan',
market_status_suspended: 'Digantung',
market_status_closed: 'Ditutup',
back: 'Kembali',
refresh: 'Muat semula',
download: 'Muat turun',
@@ -386,12 +464,18 @@ export default {
slip_tab_parlay: 'Parlay',
slip_parlay_empty_hint: 'Pilih satu odds, kemudian ketik Tambah ke parlay',
slip_add_parlay: 'Tambah ke parlay',
slip_parlay_only_hint: 'Pasaran ini hanya parlay. Ketik Tambah ke parlay untuk bertaruh.',
slip_parlay_same_match: 'Pilihan parlay tidak boleh daripada perlawanan sama. Buang pilihan perlawanan sama dahulu.',
slip_parlay_count: '{n} pilihan parlay',
slip_total_stake: 'Jumlah pertaruhan',
slip_currency: 'Amaun',
slip_min: 'Min',
slip_min_error: 'Jumlah minimum ialah {amount}',
odds_changed: 'Odds beberapa pilihan telah berubah. Terima untuk teruskan.',
odds_suspended: 'Beberapa pilihan digantung atau ditutup. Buang untuk teruskan.',
accept_changes_place: 'Terima perubahan & pertaruh',
odds_was: 'Asal',
odds_now: 'Baharu',
place_success: 'Pertaruhan berjaya',
place_failed: 'Pertaruhan gagal',
},

View File

@@ -8,11 +8,16 @@ export default {
load_failed: '加载失败',
retry: '重试',
back_to_top: '回到顶部',
cancel: '取消',
confirm: '确定',
},
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
home: {
hot_matches: '热门赛事',
hot_tab: '热门',
upcoming_tab: '近期',
no_matches: '暂无赛事',
upcoming_empty: '未来 3 天内暂无赛事',
announcement_badge: '公告',
announcement_default:
'欢迎光临 TheBet365 · 足球赛事火热进行中 · 理性投注,量力而行',
@@ -20,6 +25,70 @@ export default {
banner_next: '下一张',
banner_slide: '第 {n} 张',
banner_fallback: 'Banner',
bento_desc: '探索全球热门体育赛事',
view_all_matches: '查看全部赛事',
view_bet_history: '查看历史投注',
},
announcements: {
title: '公告中心',
detail_title: '公告详情',
empty: '暂无公告',
not_found: '公告不存在或已下线',
view_all: '查看全部公告',
type_notice: '公告',
type_ticker: '跑马灯',
type_banner: 'Banner',
related_link: '相关链接',
go_link: '前往页面',
open_link: '打开链接',
back: '返回',
},
search: {
placeholder: '搜索球队或联赛',
no_results: '未找到相关赛事',
results_count: '找到 {count} 场赛事',
hint: '输入球队或联赛名称,跳转至投注页筛选',
},
deposit_notify: {
approved_title: '充值已到账',
rejected_title: '充值未通过',
view_history: '查看充值记录',
view_messages: '查看消息中心',
dismiss: '关闭',
},
messages: {
title: '消息中心',
detail_title: '消息详情',
empty: '暂无消息',
not_found: '消息不存在',
view_all: '返回消息列表',
back: '返回',
mark_all_read: '全部已读',
load_more: '加载更多',
delete: '删除',
delete_all: '全部删除',
delete_confirm: '确定删除这条消息吗?',
delete_all_confirm: '确定删除全部消息吗?此操作不可恢复。',
banner_promo_view: '查看推广',
content_promo_view: '查看详情',
status_unread: '未读',
status_read: '已读',
deposit_approved_title: '充值已到账',
deposit_rejected_title: '充值未通过',
deposit_approved_body: '订单 {orderNo} 已审核通过,申请 {amount},到账 {approvedAmount}。',
deposit_rejected_body: '订单 {orderNo}{amount})未通过审核。{reason}',
reject_reason: '拒绝原因',
no_reason: '未提供原因',
view_recharge_history: '查看充值记录',
unread_badge: '{count} 条未读',
open_inbox: '打开消息中心',
},
inbox_hub: {
title: '消息与客服',
tab_messages: '邮箱',
tab_support: '客服',
open: '打开消息与客服',
open_support: '打开客服',
},
history: {
league_default: '足球',
@@ -235,7 +304,7 @@ export default {
audit_amount: '金额',
audit_credited: '入账金额',
audit_remark_label: '备注',
audit_summary: '审核记录 · {count} ',
audit_summary: '审核记录 · {count} ',
audit_toggle_show: '查看审核记录',
audit_toggle_hide: '收起审核记录',
view_detail: '查看详情',
@@ -266,6 +335,13 @@ export default {
tab_outright: '优胜冠军',
tab_today: '今日',
tab_early: '早盘',
filter_status_all: '全部',
filter_status_open: '待开赛',
filter_status_settled: '已结束',
filter_leagues_all: '全部联赛',
status_open: '待开赛',
status_live: '进行中',
match_unit: '场',
show_open_only: '仅显示待开赛',
show_all_matches: '显示全部',
today: '今日',
@@ -297,6 +373,8 @@ export default {
parlay_block_quarter: '四分盘让球/大小不可串关',
parlay_block_not_allowed: '该玩法不可串关',
parlay_need_more: '请至少选择 2 项进行串关',
market_status_suspended: '暂停',
market_status_closed: '已关闭',
back: '返回',
refresh: '刷新',
download: '下载',
@@ -380,12 +458,18 @@ export default {
slip_tab_parlay: '串关',
slip_parlay_empty_hint: '先选择一个下注项,再点「加入串关」',
slip_add_parlay: '加入串关',
slip_parlay_only_hint: '此盘口不支持单关,请点「加入串关」后再投注',
slip_parlay_same_match: '串关选项不得为同一场比赛,请先移除同场选项后再加入。',
slip_parlay_count: '{n} 项串关',
slip_total_stake: '总投注金额',
slip_currency: '金额',
slip_min: '最低',
slip_min_error: '最低投注金额为 {amount}',
odds_changed: '部分选项赔率已变更,请确认后下注',
odds_suspended: '部分选项已暂停或关闭,请移除后重试',
accept_changes_place: '接受变更并下注',
odds_was: '原赔率',
odds_now: '新赔率',
place_success: '下注成功',
place_failed: '下注失败',
},

View File

@@ -13,17 +13,19 @@ import BackToTopButton from '../components/BackToTopButton.vue';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
const CustomerServiceModal = defineAsyncComponent(
() => import('../components/CustomerServiceModal.vue'),
);
import { usePlayerHome } from '../composables/usePlayerHome';
import { useInboxFeature } from '../composables/useInboxFeature';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useDepositNotifications } from '../composables/useDepositNotifications';
import { usePlayerMessages } from '../composables/usePlayerMessages';
import { startPresencePing, stopPresencePing } from '../composables/usePresencePing';
const { t, locale } = useI18n();
const auth = useAuthStore();
const { initFromUser } = useAppLocale();
const route = useRoute();
const slip = useBetSlipStore();
const { inboxEnabled, hubRoute, hubOpenLabelKey } = useInboxFeature();
const isDetailPage = computed(() => {
const p = route.path;
@@ -32,31 +34,40 @@ const isDetailPage = computed(() => {
p.startsWith('/bet/') ||
p.startsWith('/bets/') ||
p.startsWith('/wallet/') ||
p.startsWith('/messages') ||
p === '/profile/edit' ||
p === '/profile/cashbacks'
);
});
const showHeader = computed(() => !isDetailPage.value);
const showAnnouncement = computed(() => !isDetailPage.value && !route.path.startsWith('/profile'));
const showAnnouncement = computed(
() => !isDetailPage.value && !route.path.startsWith('/profile') && !route.path.startsWith('/announcements'),
);
const showBottomNav = computed(() => {
const p = route.path;
if (
p === '/' ||
p === '/bet' ||
p === '/announcements' ||
p.startsWith('/announcements/') ||
p.startsWith('/match/') ||
p === '/bets' ||
p === '/wallet' ||
p === '/profile'
) return true;
// 邮箱关闭时客服页保留底部导航,避免用户无法离开
if (!inboxEnabled.value && p === '/messages') return true;
return false;
});
const { announcements, 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 customerServiceOpen = ref(false);
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadPlayerHome(true);
@@ -87,6 +98,13 @@ watch(
// 个人资料仅登录用户需要
if (token) {
void loadProfile(true);
startPolling();
startPresencePing();
if (inboxEnabled.value) void refreshUnreadCount();
} else {
stopPolling();
stopPresencePing();
resetMessagesState();
}
},
{ immediate: true },
@@ -96,6 +114,9 @@ watch(
() => route.path,
(path) => {
if (!auth.token) return;
if (inboxEnabled.value && path.startsWith('/messages')) {
void refreshUnreadCount();
}
if (balanceRefreshPaths.some((p) => path === p || path.startsWith(`${p}/`))) {
void refreshProfile();
}
@@ -106,13 +127,16 @@ watch(
<template>
<div 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">
<button
type="button"
<RouterLink
:to="hubRoute"
class="support-btn"
:aria-label="t('support.open')"
@click="customerServiceOpen = true"
:aria-label="inboxEnabled && unreadCount ? t('messages.unread_badge', { count: unreadCount }) : t(hubOpenLabelKey)"
>
<svg class="support-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
@@ -126,8 +150,9 @@ watch(
<circle cx="12" cy="11" r="1" fill="currentColor" />
<circle cx="15" cy="11" r="1" fill="currentColor" />
</svg>
<span class="support-label">{{ t('support.short') }}</span>
</button>
<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 />
@@ -140,10 +165,14 @@ watch(
</header>
<div v-if="showAnnouncement" class="announce-strip">
<AnnouncementMarquee :items="announcements" embedded />
<AnnouncementMarquee
:items="announcements"
:target-id="primaryAnnouncementId"
embedded
/>
</div>
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav }]">
<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" />
@@ -182,7 +211,6 @@ watch(
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
<BetSlipDrawer v-model="slip.drawerOpen" />
<CustomerServiceModal v-model="customerServiceOpen" />
</div>
</template>
@@ -193,22 +221,27 @@ watch(
height: 100%;
min-height: 100dvh;
overflow: hidden;
background: transparent;
background: var(--bg-body);
}
.header {
flex-shrink: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px;
background: rgba(17, 17, 17, 0.94);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
height: 48px;
background: #FFFFFF;
border-bottom: 1px solid var(--border);
z-index: 110;
}
.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;
@@ -224,59 +257,84 @@ watch(
width: var(--header-chip-h);
}
.header-divider {
width: 1px;
height: 18px;
background: #E5E7EB;
flex-shrink: 0;
margin: 0 2px;
}
.support-btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: var(--header-chip-h, 36px);
padding: 0 10px;
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);
justify-content: center;
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: 700;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 1;
min-width: 0;
transition: background 0.15s, border-color 0.15s;
flex-shrink: 0;
position: relative;
text-decoration: none;
}
.support-btn:active {
background: rgba(200, 168, 78, 0.16);
background: var(--bg-hover);
border-color: var(--border-active);
}
.support-icon {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
flex-shrink: 0;
}
.hub-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
pointer-events: none;
}
.support-label {
max-width: 48px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
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;
border-bottom: 1px solid var(--border);
}
@@ -286,17 +344,19 @@ watch(
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 12px 16px 0;
padding: 10px 14px 0;
}
.main.has-nav {
padding-bottom: 16px;
}
.main.main--detail {
padding-top: 0;
}
.bottom-nav {
display: flex;
background: rgba(17, 17, 17, 0.96);
background: #FFFFFF;
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35);
z-index: 100;
padding-bottom: env(safe-area-inset-bottom, 0);
}
@@ -307,15 +367,16 @@ watch(
align-items: center;
justify-content: center;
gap: 3px;
padding: 6px 2px 8px;
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 {
@@ -327,22 +388,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,6 +15,10 @@ 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/AnnouncementListView.vue') },
{ path: 'announcements/:id', component: () => import('../views/AnnouncementDetailView.vue') },
{ path: 'messages', component: () => import('../views/InboxHubView.vue'), meta: { keepAlive: true, requiresAuth: false } },
{ path: 'messages/:id', component: () => import('../views/MessageDetailView.vue'), meta: { requiresAuth: true } },
{ path: 'match/:id', component: () => import('../views/MatchDetailView.vue') },
// 需要登录的页面
{ path: 'bets', component: () => import('../views/MyBetsView.vue'), meta: { keepAlive: true, requiresAuth: true } },

View File

@@ -18,6 +18,7 @@ export interface SlipItem {
odds: number;
marketType: string;
lineValue?: number | null;
allowSingle?: boolean;
allowParlay?: boolean;
}
@@ -178,6 +179,16 @@ export const useBetSlipStore = defineStore('betSlip', () => {
drawerOpen.value = false;
}
function updateSelectionOdds(selectionId: string, odds: number, oddsVersion: string) {
if (singleItem.value?.selectionId === selectionId) {
singleItem.value = { ...singleItem.value, odds, oddsVersion };
}
const idx = parlayItems.value.findIndex((i) => i.selectionId === selectionId);
if (idx >= 0) {
parlayItems.value[idx] = { ...parlayItems.value[idx], odds, oddsVersion };
}
}
return {
singleItem,
parlayItems,
@@ -208,5 +219,6 @@ export const useBetSlipStore = defineStore('betSlip', () => {
clearAll,
openDrawer,
closeDrawer,
updateSelectionOdds,
};
});

View File

@@ -1,236 +1,72 @@
* { 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%);
--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);
}
/** 金色描边按钮(避免大面积实心填充) */
.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);
}
/** 选中态:浅底 + 金边,非整块金色 */
.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%;
}
/* ── Global resets ── */
html, body, #app { height: 100%; }
body {
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;
}
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: 15px;
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,
@@ -239,13 +75,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;
@@ -254,25 +161,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;
@@ -281,15 +177,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);
@@ -297,20 +192,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;
@@ -322,4 +217,4 @@ input:-webkit-autofill:active {
color: var(--text-muted);
font-weight: 600;
font-size: 14px;
}
}

View File

@@ -0,0 +1,65 @@
const ALLOWED_TAGS = new Set([
'p', 'br', 'strong', 'b', 'em', 'i', 'u', 'ul', 'ol', 'li',
'img', 'a', 'h2', 'h3', 'blockquote', 'div', 'span',
]);
function sanitizeNode(node: Node): Node | null {
if (node.nodeType === Node.TEXT_NODE) {
return node.cloneNode(false);
}
if (node.nodeType !== Node.ELEMENT_NODE) return null;
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
const frag = document.createDocumentFragment();
for (const child of Array.from(el.childNodes)) {
const safe = sanitizeNode(child);
if (safe) frag.appendChild(safe);
}
return frag;
}
const out = document.createElement(tag);
if (tag === 'img') {
const src = el.getAttribute('src')?.trim();
if (!src || /^javascript:/i.test(src)) return null;
out.setAttribute('src', src);
const alt = el.getAttribute('alt');
if (alt) out.setAttribute('alt', alt);
return out;
}
if (tag === 'a') {
const href = el.getAttribute('href')?.trim();
if (!href || /^javascript:/i.test(href)) return null;
out.setAttribute('href', href);
out.setAttribute('target', '_blank');
out.setAttribute('rel', 'noopener noreferrer');
}
for (const child of Array.from(el.childNodes)) {
const safe = sanitizeNode(child);
if (safe) out.appendChild(safe);
}
return out;
}
/** 玩家端公告正文 HTML 白名单净化 */
export function sanitizeAnnouncementHtml(html: string): string {
if (!html?.trim()) return '';
if (!/[<>]/.test(html)) return html;
const doc = new DOMParser().parseFromString(html, 'text/html');
const container = document.createElement('div');
for (const child of Array.from(doc.body.childNodes)) {
const safe = sanitizeNode(child);
if (safe) container.appendChild(safe);
}
return container.innerHTML;
}
export function stripHtml(html: string): string {
if (!html) return '';
if (!/[<>]/.test(html)) return html.trim();
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
}

View File

@@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed, onActivated, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../components/GoldSpinner.vue';
import defaultBannerImg from '../assets/images/banner.webp';
import { usePlayerHome, type PlayerContentItem } from '../composables/usePlayerHome';
import { sanitizeAnnouncementHtml, stripHtml } from '../utils/html';
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { announcementItems, bannerItems, loading, load } = usePlayerHome();
const announcementId = computed(() => String(route.params.id ?? ''));
const item = computed<PlayerContentItem | null>(() => {
const id = announcementId.value;
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() {
router.back();
}
function goList() {
router.push('/announcements');
}
function formatDate(createdAt?: string) {
if (!createdAt) return '';
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
}
function itemTitle(entry: PlayerContentItem) {
const title = entry.translation?.title?.trim();
if (title) return title;
const bodyText = stripHtml(entry.translation?.body ?? '');
return bodyText || t('home.announcement_badge');
}
function itemBodyHtml(entry: PlayerContentItem) {
const title = entry.translation?.title?.trim();
const body = entry.translation?.body?.trim();
if (!body) return '';
if (title && stripHtml(body) === title) return '';
return sanitizeAnnouncementHtml(body);
}
const heroImageUrl = computed(() => {
const url = item.value?.translation?.imageUrl?.trim();
if (url) return url;
if (isBanner.value) return defaultBannerImg || FALLBACK_IMG;
return '';
});
const linkTarget = computed(() => item.value?.linkTarget?.trim() ?? '');
const externalLinkUrl = computed(() => {
if (item.value?.linkType !== 'URL' || !linkTarget.value) return '';
return /^https?:\/\//i.test(linkTarget.value)
? linkTarget.value
: `https://${linkTarget.value}`;
});
function onHeroError(e: Event) {
const img = e.target as HTMLImageElement;
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = '1';
img.src = defaultBannerImg || FALLBACK_IMG;
}
function followRouteLink() {
if (item.value?.linkType === 'ROUTE' && linkTarget.value) {
void router.push(linkTarget.value);
}
}
function openExternalLink() {
if (externalLinkUrl.value) {
window.open(externalLinkUrl.value, '_blank', 'noopener');
}
}
async function refresh() {
await load(true);
}
onActivated(() => {
void refresh();
});
watch(announcementId, () => {
if (!item.value && !loading.value) void refresh();
});
</script>
<template>
<div class="announce-detail">
<header class="page-header">
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack"></button>
<h1>{{ t('announcements.detail_title') }}</h1>
</header>
<div v-if="loading && !item" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="!item" class="empty">
<p>{{ t('announcements.not_found') }}</p>
<button type="button" class="link-btn" @click="goList">{{ t('announcements.view_all') }}</button>
</div>
<article v-else class="detail-article">
<figure v-if="heroImageUrl" class="detail-hero">
<img :src="heroImageUrl" :alt="itemTitle(item)" loading="lazy" @error="onHeroError" />
</figure>
<div class="detail-body-wrap">
<p v-if="item.createdAt" class="detail-date">{{ formatDate(item.createdAt) }}</p>
<h2 class="detail-title">{{ itemTitle(item) }}</h2>
<div v-if="itemBodyHtml(item)" class="detail-body" v-html="itemBodyHtml(item)" />
<div v-if="item.linkType && linkTarget" class="detail-link">
<p class="link-label">{{ t('announcements.related_link') }}</p>
<p class="link-address">{{ linkTarget }}</p>
<button
v-if="item.linkType === 'ROUTE'"
type="button"
class="link-action"
@click="followRouteLink"
>
{{ t('announcements.go_link') }}
</button>
<button
v-else-if="item.linkType === 'URL'"
type="button"
class="link-action link-action--outline"
@click="openExternalLink"
>
{{ t('announcements.open_link') }}
</button>
</div>
</div>
</article>
</div>
</template>
<style scoped>
.announce-detail {
min-height: 100%;
padding: 0 0 24px;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 14px;
margin-bottom: 0;
border-bottom: 1px solid var(--border);
}
.back-btn {
width: 36px;
height: 36px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--primary-dark, #002847);
}
.state,
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
color: var(--text-muted);
}
.link-btn {
border: 1px solid var(--border-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.detail-article {
margin: 0;
padding: 0;
}
.detail-hero {
margin: 16px -16px 0;
padding: 0;
background: var(--bg-body);
line-height: 0;
border-radius: 0;
}
.detail-hero img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
object-position: center;
}
.detail-body-wrap {
padding: 18px 0 0;
}
.detail-date {
margin: 0 0 10px;
font-size: 12px;
color: var(--text-muted);
}
.detail-title {
margin: 0 0 12px;
font-size: 20px;
font-weight: 700;
line-height: 1.45;
color: var(--primary-dark, #002847);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #374151;
}
.detail-body :deep(p) {
margin: 0 0 12px;
}
.detail-body :deep(p:last-child) {
margin-bottom: 0;
}
.detail-body :deep(img) {
max-width: 100%;
height: auto;
display: block;
margin: 14px 0;
border-radius: 8px;
}
.detail-body :deep(ul),
.detail-body :deep(ol) {
margin: 0 0 12px;
padding-left: 20px;
}
.detail-body :deep(a) {
color: var(--primary-light);
text-decoration: underline;
}
.detail-link {
margin-top: 24px;
padding-top: 18px;
border-top: 1px solid var(--border);
}
.link-label {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-muted);
}
.link-address {
margin: 0 0 14px;
font-size: 14px;
line-height: 1.55;
color: var(--primary-light);
word-break: break-all;
}
.link-action {
width: 100%;
padding: 11px 14px;
border: none;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.link-action--outline {
background: transparent;
border: 1px solid var(--border-active);
color: var(--primary);
}
</style>

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { computed, onActivated } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { formatLocalMatchDateTime } from '@thebet365/shared';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePlayerHome } from '../composables/usePlayerHome';
import { stripHtml } from '../utils/html';
const router = useRouter();
const { t, locale } = useI18n();
const { announcementItems, loading, load } = usePlayerHome();
const items = computed(() => announcementItems.value);
function goBack() {
router.back();
}
function openDetail(id: string) {
router.push(`/announcements/${id}`);
}
function formatDate(createdAt?: string) {
if (!createdAt) return '';
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
}
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: (typeof items.value)[number]) {
const title = item.translation?.title?.trim();
const bodyText = stripHtml(item.translation?.body ?? '');
if (bodyText && bodyText !== title) return bodyText;
return '';
}
onActivated(() => {
void load(true);
});
</script>
<template>
<div class="announce-page">
<header class="page-header">
<button type="button" class="back-btn" :aria-label="t('announcements.back')" @click="goBack"></button>
<h1>{{ t('announcements.title') }}</h1>
</header>
<div v-if="loading && !items.length" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="!items.length" class="empty">
<p>{{ t('announcements.empty') }}</p>
</div>
<div v-else class="list">
<button
v-for="item in items"
:key="item.id"
type="button"
class="list-row"
@click="openDetail(item.id)"
>
<span class="row-main">
<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>
<span class="chevron" aria-hidden="true"></span>
</button>
</div>
</div>
</template>
<style scoped>
.announce-page {
min-height: 100%;
padding: 0 0 24px;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.back-btn {
width: 36px;
height: 36px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--primary-dark, #002847);
}
.state,
.empty {
display: flex;
justify-content: center;
padding: 48px 16px;
color: var(--text-muted);
}
.list {
display: flex;
flex-direction: column;
}
.list-row {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 16px 0;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
text-align: left;
color: inherit;
cursor: pointer;
}
.row-main {
flex: 1;
min-width: 0;
}
.title {
display: block;
font-size: 15px;
font-weight: 600;
color: var(--primary-dark, #002847);
line-height: 1.4;
}
.preview {
display: block;
margin-top: 6px;
font-size: 13px;
line-height: 1.45;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
display: block;
margin-top: 8px;
font-size: 12px;
color: var(--text-muted);
}
.chevron {
flex-shrink: 0;
font-size: 20px;
color: var(--text-muted);
line-height: 1;
}
</style>

View File

@@ -275,7 +275,7 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.back-btn {
background: none;
border: none;
color: var(--primary-light, #d4af37);
color: #003D6B;
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
@@ -288,14 +288,14 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.state {
text-align: center;
padding: 60px 20px;
color: #666;
color: #6B7280;
font-size: 14px;
font-weight: 600;
}
/* ── hero ── */
.hero {
border-radius: 14px;
border-radius: 12px;
padding: 20px 20px 18px;
display: flex;
flex-direction: column;
@@ -305,22 +305,22 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
text-align: center;
}
.hero.won { background: linear-gradient(135deg, rgba(61,184,101,0.15), rgba(61,184,101,0.06)); border: 1px solid rgba(61,184,101,0.25); }
.hero.lost { background: linear-gradient(135deg, rgba(224,80,80,0.12), rgba(224,80,80,0.05)); border: 1px solid rgba(224,80,80,0.2); }
.hero.pending { background: linear-gradient(135deg, rgba(232,200,74,0.1), rgba(232,200,74,0.04)); border: 1px solid rgba(232,200,74,0.2); }
.hero.push { background: #181818; border: 1px solid #2a2a2a; }
.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.7;
opacity: 0.85;
}
.hero.won .hero-status { color: #3db865; }
.hero.lost .hero-status { color: #e05050; }
.hero.pending .hero-status { color: #e8c84a; }
.hero.push .hero-status { color: #888; }
.hero.won .hero-status { color: #059669; }
.hero.lost .hero-status { color: #DC2626; }
.hero.pending .hero-status { color: #F8971F; }
.hero.push .hero-status { color: #6B7280; }
.hero-return {
font-size: 36px;
@@ -328,25 +328,26 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
letter-spacing: -0.01em;
line-height: 1.1;
}
.hero.won .hero-return { color: #3db865; text-shadow: 0 0 24px rgba(61,184,101,0.3); }
.hero.lost .hero-return { color: #e05050; }
.hero.pending .hero-return { color: #e8c84a; }
.hero.push .hero-return { color: #777; }
.hero.won .hero-return { color: #059669; }
.hero.lost .hero-return { color: #DC2626; }
.hero.pending .hero-return { color: #F8971F; }
.hero.push .hero-return { color: #6B7280; }
.hero-return-label {
font-size: 10.5px;
color: #555;
color: #6B7280;
font-weight: 600;
letter-spacing: 0.04em;
}
/* ── sections ── */
.section {
background: #141414;
border: 1px solid #222;
border-radius: 12px;
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);
}
.section-head {
@@ -358,20 +359,20 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.league-tag {
font-size: 11px;
color: #888;
color: #6B7280;
font-weight: 700;
}
.placed-time {
font-size: 11px;
color: #555;
color: #6B7280;
font-weight: 600;
}
.match-name {
font-size: 17px;
font-weight: 900;
color: #f0f0f0;
color: #1A1A2E;
line-height: 1.3;
margin-bottom: 10px;
}
@@ -398,18 +399,18 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.row-label {
font-size: 11px;
color: #666;
color: #6B7280;
font-weight: 600;
}
.row-val {
font-size: 13px;
font-weight: 700;
color: #c8c8c8;
color: #1A1A2E;
}
.row-val.pick {
color: #d4af37;
color: #003D6B;
}
.score-chips {
@@ -421,15 +422,15 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
display: flex;
align-items: center;
gap: 5px;
background: #1e1e1e;
border: 1px solid #2a2a2a;
background: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 7px;
padding: 5px 10px;
}
.score-period {
font-size: 10px;
color: #666;
color: #6B7280;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
@@ -438,24 +439,24 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.score-value {
font-size: 16px;
font-weight: 900;
color: #e8e8e8;
color: #1A1A2E;
}
.score-value.muted {
color: #666;
color: #6B7280;
font-size: 13px;
}
.no-score {
font-size: 11.5px;
color: #555;
color: #6B7280;
font-style: italic;
}
/* section title */
.section-title {
font-size: 10.5px;
color: #555;
color: #6B7280;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
@@ -474,14 +475,14 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
justify-content: space-between;
align-items: flex-start;
gap: 10px;
background: #1a1a1a;
border: 1px solid #252525;
border-radius: 9px;
background: #F5F7FA;
border: 1px solid #E5E7EB;
border-radius: 8px;
padding: 10px 12px;
}
.leg-row.won { border-color: rgba(61,184,101,0.2); }
.leg-row.lost { border-color: rgba(224,80,80,0.18); }
.leg-row.won { border-color: rgba(5, 150, 105, 0.25); }
.leg-row.lost { border-color: rgba(220, 38, 38, 0.2); }
.leg-left {
display: flex;
@@ -496,8 +497,8 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
width: 20px;
height: 20px;
border-radius: 50%;
background: #2a2a2a;
color: #666;
background: #E8EDF2;
color: #6B7280;
font-size: 10px;
font-weight: 800;
display: flex;
@@ -506,8 +507,8 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
margin-top: 1px;
}
.leg-num.won { background: rgba(61,184,101,0.18); color: #3db865; }
.leg-num.lost { background: rgba(224,80,80,0.18); color: #e05050; }
.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;
@@ -519,12 +520,12 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.leg-match {
font-size: 12.5px;
font-weight: 800;
color: #d0d0d0;
color: #1A1A2E;
}
.leg-pick-line {
font-size: 11.5px;
color: #888;
color: #6B7280;
font-weight: 600;
}
@@ -537,11 +538,11 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.leg-scores span {
font-size: 11px;
font-weight: 700;
color: #c8c8c8;
color: #1A1A2E;
}
.leg-scores .muted-score {
color: #555;
color: #6B7280;
}
.leg-right {
@@ -555,7 +556,7 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
.leg-odds-val {
font-size: 15px;
font-weight: 900;
color: #b8a04a;
color: #003D6B;
}
.leg-result-badge {
@@ -567,10 +568,10 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
border-radius: 10px;
}
.leg-result-badge.won { color: #3db865; background: rgba(61,184,101,0.12); }
.leg-result-badge.lost { color: #e05050; background: rgba(224,80,80,0.1); }
.leg-result-badge.push { color: #888; background: #222; }
.leg-result-badge.pending { color: #666; background: #1e1e1e; }
.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 {
@@ -584,9 +585,9 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #1c1c1c;
border-bottom: 1px solid #E5E7EB;
font-size: 13px;
color: #b0b0b0;
color: #1A1A2E;
font-weight: 600;
}
@@ -595,28 +596,28 @@ const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
}
.sum-row.muted {
color: #444;
color: #6B7280;
font-size: 11px;
}
.odds-val {
color: #b8a04a;
color: #F8971F;
font-weight: 800;
}
.amt-won {
color: #3db865;
color: #059669;
font-weight: 900;
font-size: 15px;
}
.amt-pending {
color: #e8c84a;
color: #F8971F;
font-weight: 900;
}
.amt-lost {
color: #e05050;
color: #DC2626;
font-weight: 900;
font-size: 15px;
}

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

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { ref, computed, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { usePlayerMatches } from '../composables/usePlayerMatches';
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import MatchBetCard from '../components/MatchBetCard.vue';
import OutrightPanel from '../components/outright/OutrightPanel.vue';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
@@ -16,7 +17,6 @@ import {
} from '@thebet365/shared';
type MainTab = 'matches' | 'outright';
type TimeTab = 'today' | 'early';
interface Match {
id: string;
@@ -60,19 +60,20 @@ interface LeagueGroup {
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const mainTab = ref<MainTab>('matches');
const timeTab = ref<TimeTab>('today');
const showAll = ref(false);
const searchQuery = ref('');
const filterState = ref({
time: 'all' as 'all' | 'today' | 'early',
status: 'open' as 'all' | 'open' | 'settled',
leagueIds: [] as string[]
});
const outrightActivated = ref(false);
const filterNow = ref(new Date());
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
const matches = summaryMatches;
const loading = summaryLoading;
const expandedLeagues = ref<Record<TimeTab, Set<string>>>({
today: new Set(),
early: new Set(),
});
async function loadMatches() {
filterNow.value = new Date();
@@ -90,18 +91,94 @@ const pullIndicatorStyle = () => ({
opacity: Math.min(pullDistance.value / 48, 1),
});
function filterMatchesForTab(tab: TimeTab) {
function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function syncSearchFromRoute() {
const q = route.query.search;
searchQuery.value = typeof q === 'string' ? q : '';
}
function matchesSearchKeyword(m: Match, keyword: string) {
if (!keyword) return true;
const haystack = `${m.homeTeamName} ${m.awayTeamName} ${m.leagueName}`.toLowerCase();
return haystack.includes(keyword);
}
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return [];
const now = filterNow.value;
const keyword = searchQuery.value.trim().toLowerCase();
return matches.value.filter((m) => {
const timeMatch =
tab === 'today'
? isInTodayMatchWindow(m.startTime, now)
: isAfterTodayMatchWindow(m.startTime, now);
if (!matchesSearchKeyword(m, keyword)) return false;
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) return false;
if (!showAll.value && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') return false;
if (filterState.value.leagueIds.length > 0) {
const id = m.leagueId ?? m.leagueName;
if (!filterState.value.leagueIds.includes(id)) return false;
}
return true;
});
});
const availableLeagues = computed(() => {
const map = new Map<string, { id: string, name: string }>();
for (const m of matches.value) {
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, filterNow.value);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, filterNow.value);
}
if (!timeMatch) continue;
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
map.set(id, { id, name: normalizeLeagueName(m.leagueName) });
}
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
});
watch(() => filterState.value.time, () => {
filterState.value.leagueIds = [];
});
const dropdownOpen = ref(false);
const selectedLeagueName = computed(() => {
if (filterState.value.leagueIds.length === 0) return '';
const firstId = filterState.value.leagueIds[0];
const lg = availableLeagues.value.find(l => l.id === firstId);
return lg ? lg.name : '';
});
function selectLeague(id: string) {
if (id === '') {
filterState.value.leagueIds = [];
} else {
filterState.value.leagueIds = [id];
}
dropdownOpen.value = false;
}
function toggleStatusOpen() {
filterState.value.status = filterState.value.status === 'open' ? 'all' : 'open';
}
function buildLeagueGroups(source: Match[]): LeagueGroup[] {
@@ -111,7 +188,7 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
if (!map.has(id)) {
map.set(id, {
leagueId: id,
leagueName: m.leagueName,
leagueName: normalizeLeagueName(m.leagueName),
leagueLogoUrl: m.leagueLogoUrl ?? null,
matches: [],
});
@@ -133,32 +210,38 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
);
}
const todayLeagueGroups = computed(() => buildLeagueGroups(filterMatchesForTab('today')));
const earlyLeagueGroups = computed(() => buildLeagueGroups(filterMatchesForTab('early')));
const leagueGroups = computed(() => buildLeagueGroups(filteredMatches.value));
function syncExpandedLeagues(groups: LeagueGroup[], tab: TimeTab) {
const current = expandedLeagues.value[tab];
const ids = new Set(current);
const isSearchActive = computed(() => searchQuery.value.trim().length > 0);
const sortedFilteredMatches = computed(() =>
[...filteredMatches.value].sort(
(a, b) =>
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
),
);
const expandedLeagues = ref(new Set<string>());
watch(leagueGroups, (groups) => {
const ids = new Set(expandedLeagues.value);
for (const id of [...ids]) {
if (!groups.some((g) => g.leagueId === id)) ids.delete(id);
}
if (groups.length > 0 && ids.size === 0) {
ids.add(groups[0].leagueId);
}
if (ids.size !== current.size || [...ids].some((id) => !current.has(id))) {
expandedLeagues.value = { ...expandedLeagues.value, [tab]: ids };
if (ids.size !== expandedLeagues.value.size || [...ids].some((id) => !expandedLeagues.value.has(id))) {
expandedLeagues.value = ids;
}
}
watch(todayLeagueGroups, (groups) => syncExpandedLeagues(groups, 'today'));
watch(earlyLeagueGroups, (groups) => syncExpandedLeagues(groups, 'early'));
});
function toggleLeague(leagueId: string) {
const tab = timeTab.value;
const next = new Set(expandedLeagues.value[tab]);
const next = new Set(expandedLeagues.value);
if (next.has(leagueId)) next.delete(leagueId);
else next.add(leagueId);
expandedLeagues.value = { ...expandedLeagues.value, [tab]: next };
expandedLeagues.value = next;
}
function selectMainTab(tab: MainTab) {
@@ -168,10 +251,16 @@ function selectMainTab(tab: MainTab) {
onActivated(() => {
filterNow.value = new Date();
timeTab.value = 'today';
filterState.value.time = 'all';
syncSearchFromRoute();
void loadSummary(true, true);
});
watch(
() => route.query.search,
() => syncSearchFromRoute(),
);
function goMatch(id: string) {
router.push(`/match/${id}`);
}
@@ -208,79 +297,123 @@ function goMatch(id: string) {
</div>
<div :class="['tab-panel', { 'tab-panel--hidden': mainTab !== 'matches' }]">
<div class="time-tabs">
<button
type="button"
class="time-tab"
:class="{ active: timeTab === 'today', 'tab-gold-active': timeTab === 'today' }"
@click="timeTab = 'today'"
>
{{ t('bet.tab_today') }}
</button>
<button
type="button"
class="time-tab"
:class="{ active: timeTab === 'early', 'tab-gold-active': timeTab === 'early' }"
@click="timeTab = 'early'"
>
{{ t('bet.tab_early') }}
</button>
<div class="search-bar">
<span class="search-icon" aria-hidden="true"></span>
<input
v-model="searchQuery"
type="search"
:placeholder="t('search.placeholder')"
enterkeyhint="search"
autocomplete="off"
/>
</div>
<div class="phase-filter">
<button
type="button"
class="phase-toggle"
:class="{ 'phase-toggle--active': !showAll }"
:aria-pressed="!showAll"
@click="showAll = !showAll"
>
<span class="phase-toggle-dot" />
{{ t('bet.show_open_only') }}
</button>
<div class="filters-bar">
<div class="phase-filter">
<div class="league-dropdown">
<button type="button" class="dropdown-trigger" @click="dropdownOpen = !dropdownOpen">
<span class="dropdown-text">{{ selectedLeagueName || t('bet.filter_leagues_all') }}</span>
<span class="arrow-icon" :class="{ open: dropdownOpen }"></span>
</button>
<div v-if="dropdownOpen" class="dropdown-backdrop" @click="dropdownOpen = false"></div>
<div v-if="dropdownOpen" class="dropdown-menu">
<div
class="dropdown-item"
:class="{ active: filterState.leagueIds.length === 0 }"
@click="selectLeague('')"
>
{{ t('bet.filter_leagues_all') }}
</div>
<div
v-for="lg in availableLeagues"
:key="lg.id"
class="dropdown-item"
:class="{ active: filterState.leagueIds.includes(lg.id) }"
@click="selectLeague(lg.id)"
>
{{ lg.name }}
</div>
</div>
</div>
</div>
<div class="right-filters">
<div class="time-tabs">
<button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'all' }"
@click="filterState.time = 'all'"
>
{{ t('bet.filter_status_all') }}
</button>
<button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'today' }"
@click="filterState.time = 'today'"
>
{{ t('bet.tab_today') }}
</button>
<button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'early' }"
@click="filterState.time = 'early'"
>
{{ t('bet.tab_early') }}
</button>
</div>
<div class="divider"></div>
<button
type="button"
class="status-toggle-btn"
:class="{ active: filterState.status === 'open' }"
@click="toggleStatusOpen"
>
{{ t('bet.filter_status_open') }}
</button>
</div>
</div>
<div v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<template v-else>
<div v-show="timeTab === 'today'">
<div v-if="todayLeagueGroups.length" class="league-list">
<div>
<div v-if="isSearchActive && sortedFilteredMatches.length" class="search-results">
<p class="search-results-meta">
{{ t('search.results_count', { count: sortedFilteredMatches.length }) }}
</p>
<div class="search-match-list">
<article
v-for="match in sortedFilteredMatches"
:key="match.id"
class="search-result-item"
>
<p class="search-result-league">{{ normalizeLeagueName(match.leagueName) }}</p>
<MatchBetCard :match="match" @bet="goMatch" />
</article>
</div>
</div>
<div v-else-if="!isSearchActive && leagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in todayLeagueGroups"
:key="`today-${group.leagueId}`"
v-for="group in leagueGroups"
:key="group.leagueId"
:league-id="group.leagueId"
:league-name="group.leagueName"
:league-logo-url="group.leagueLogoUrl"
:matches="group.matches"
:expanded="expandedLeagues.today.has(group.leagueId)"
:expanded="expandedLeagues.has(group.leagueId)"
@toggle="toggleLeague(group.leagueId)"
@bet="goMatch"
/>
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
</div>
</div>
<div v-show="timeTab === 'early'">
<div v-if="earlyLeagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in earlyLeagueGroups"
:key="`early-${group.leagueId}`"
:league-id="group.leagueId"
:league-name="group.leagueName"
:league-logo-url="group.leagueLogoUrl"
:matches="group.matches"
:expanded="expandedLeagues.early.has(group.leagueId)"
@toggle="toggleLeague(group.leagueId)"
@bet="goMatch"
/>
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
<p>{{ isSearchActive ? t('search.no_results') : t('bet.no_matches') }}</p>
</div>
</div>
</template>
@@ -292,6 +425,8 @@ function goMatch(id: string) {
:activated="mainTab === 'outright'"
/>
</div>
</template>
@@ -307,9 +442,10 @@ function goMatch(id: string) {
.bet-page {
margin: 0 -16px;
padding-bottom: 8px;
background: var(--bg-body);
}
/* Tab 面板切换:不用 display:none避免浏览器释放图片资源导致重新加载 */
/* Tab panel toggle: avoid display:none to prevent browser releasing image resources */
.tab-panel {
display: block;
}
@@ -339,18 +475,23 @@ function goMatch(id: string) {
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 8px;
border-radius: 10px;
background: #1a1a1a;
border: 1px solid #2a2a2a;
padding: 10px 8px;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
font-weight: 600;
position: relative;
box-shadow: var(--shadow);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.main-tab.active {
font-weight: 800;
font-weight: 700;
background: rgba(244, 162, 97, 0.1);
border-color: var(--primary);
color: var(--primary-light);
}
.tab-icon {
@@ -358,69 +499,174 @@ function goMatch(id: string) {
line-height: 1;
}
.time-tabs {
.filters-bar {
display: flex;
gap: 10px;
padding: 0 12px 14px;
}
.time-tab {
flex: 1;
padding: 10px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
background: #141414;
border: 1px solid var(--primary);
}
.time-tab.active {
background: var(--gradient-gold) !important;
border-color: #fff1a8 !important;
color: #2a1a00 !important;
font-weight: 800;
box-shadow:
0 0 0 1px rgba(255, 244, 200, 0.28) inset,
0 4px 16px rgba(212, 175, 55, 0.28);
text-shadow: 0 1px 0 rgba(255, 252, 235, 0.75);
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 0 16px 12px;
}
.phase-filter {
padding: 0 12px 10px;
padding: 0;
display: flex;
align-items: center;
}
.phase-toggle {
.right-filters {
display: flex;
align-items: center;
gap: 8px;
}
.time-tabs {
display: flex;
align-items: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 3px;
height: 26px;
box-sizing: border-box;
}
.time-tab {
padding: 0 10px;
height: 100%;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.time-tab.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
font-weight: 600;
}
.right-filters .divider {
width: 1px;
height: 12px;
background: var(--border);
}
.status-toggle-btn {
padding: 0 10px;
height: 26px;
box-sizing: border-box;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.status-toggle-btn.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
border-color: var(--primary);
font-weight: 600;
}
/* League Dropdown Selector */
.league-dropdown {
position: relative;
display: inline-block;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 999px;
padding: 0 12px;
font-size: 12px;
font-weight: 600;
color: #999;
background: #141414;
border: 1px solid #333;
font-weight: 500;
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
height: 24px;
box-sizing: border-box;
max-width: 150px;
}
.phase-toggle--active {
.dropdown-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dropdown-trigger .arrow-icon {
font-size: 8px;
color: var(--text-muted);
transition: transform 0.2s ease;
display: inline-block;
transform: scale(0.8);
}
.dropdown-trigger .arrow-icon.open {
transform: scale(0.8) rotate(180deg);
}
.dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 98;
background: transparent;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 99;
min-width: 170px;
max-width: 240px;
max-height: 200px;
overflow-y: auto;
background: #FFFFFF;
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.35);
padding: 6px 0;
}
.dropdown-item {
padding: 8px 14px;
font-size: 13px;
color: var(--text);
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dropdown-item:hover,
.dropdown-item:active {
background: var(--bg-body);
}
.dropdown-item.active {
color: var(--primary-light);
border-color: var(--primary);
background: rgba(200, 168, 78, 0.08);
}
.phase-toggle-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #555;
transition: background 0.2s ease;
}
.phase-toggle--active .phase-toggle-dot {
background: var(--primary-light);
background: rgba(244, 162, 97, 0.1);
font-weight: 700;
}
.league-list {
@@ -449,4 +695,75 @@ function goMatch(id: string) {
.outright-tab {
min-height: 0;
}
.search-bar {
display: flex;
align-items: center;
gap: 8px;
margin: 0 12px 8px;
padding: 0 10px;
height: 32px;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
}
.search-icon {
color: var(--primary-light);
font-size: 14px;
line-height: 1;
flex-shrink: 0;
}
.search-bar input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: var(--text);
font-size: 12px;
font-weight: 500;
line-height: 1.2;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.search-bar input::placeholder {
color: var(--text-muted);
}
.search-results {
padding: 0 12px;
}
.search-results-meta {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
}
.search-match-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.search-result-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.search-result-league {
margin: 0;
padding: 0 2px;
font-size: 11px;
font-weight: 700;
color: var(--primary-light);
line-height: 1.3;
}
</style>

View File

@@ -197,7 +197,7 @@ 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: linear-gradient(160deg, #E8EDF2 0%, #F5F7FA 40%, #FFFFFF 100%);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
@@ -209,14 +209,18 @@ function goLogin() {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
padding: 20px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.form-title {
margin: 0 0 2px;
font-size: 16px;
font-weight: 800;
color: #fff;
color: #1A1A2E;
text-align: center;
}
@@ -228,7 +232,7 @@ function goLogin() {
label {
font-size: 10px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
letter-spacing: 0.03em;
}
@@ -237,21 +241,22 @@ label {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #0d0d0d;
color: var(--text);
background: #FFFFFF;
color: #1A1A2E;
font-size: 14px;
font-family: inherit;
}
.field-input::placeholder {
color: rgba(255, 255, 255, 0.32);
color: rgba(107, 114, 128, 0.6);
}
.field-input:focus {
outline: none;
border-color: #555;
border-color: #0066CC;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
}
.inline-row {
@@ -269,10 +274,10 @@ label {
flex-shrink: 0;
height: 36px;
padding: 0 10px;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #141414;
color: var(--text);
background: #F5F7FA;
color: #003D6B;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
@@ -302,19 +307,19 @@ label {
padding: 8px 14px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.55);
color: #003D6B;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
color: #005B9F;
}
.error {
margin: 0;
color: var(--danger);
color: #DC2626;
font-size: 12px;
font-weight: 600;
line-height: 1.3;

View File

@@ -1,27 +1,98 @@
<script setup lang="ts">
import { onActivated } 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 { usePlayerHome } from '../composables/usePlayerHome';
import TeamEmblem from '../components/TeamEmblem.vue';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { formatLocalMatchDateTime } from '@thebet365/shared';
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, loading, load } = usePlayerHome();
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
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[]>(() => upcomingMatches.value);
const limitedMatches = computed(() => displayedMatches.value.slice(0, 6));
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`,
@@ -47,11 +118,128 @@ function formatKickoff(startTime: string) {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<BannerCarousel :banners="banners" />
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<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="section-header">
<div class="section-title">{{ t('home.upcoming_tab') }}</div>
</div>
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
<div
v-for="(match, index) in hotMatches"
v-for="(match, index) in limitedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': index < 3 }"
@@ -102,9 +290,15 @@ function formatKickoff(startTime: string) {
</div>
</div>
<div v-if="!loading && !hotMatches.length" class="empty">
<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>{{ t('home.no_matches') }}</p>
<p>{{ emptyMessage }}</p>
</div>
</div>
</template>
@@ -118,22 +312,352 @@ function formatKickoff(startTime: string) {
transition: height 0.15s ease;
}
.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-bottom: 16px;
padding: 0 2px;
}
.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;
}
.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;
color: #003D6B;
background: rgba(0, 61, 107, 0.06);
padding: 3px 6px;
border-radius: 6px;
line-height: 1;
}
.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 {
position: relative;
isolation: isolate;
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;
}
@@ -141,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,
@@ -164,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;
}
@@ -180,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;
@@ -209,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;
}
@@ -234,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);
@@ -252,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 {
@@ -343,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,
@@ -369,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

@@ -0,0 +1,299 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import MessageListPanel from '../components/MessageListPanel.vue';
import CustomerServicePanel from '../components/CustomerServicePanel.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import { useAuthStore } from '../stores/auth';
import { usePlayerMessages } from '../composables/usePlayerMessages';
import { useInboxFeature } from '../composables/useInboxFeature';
type HubTab = 'messages' | 'support';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const auth = useAuthStore();
const { inboxEnabled, hubTitleKey } = useInboxFeature();
const {
unreadCount,
messages,
listLoaded,
refreshUnreadCount,
markAllRead,
deleteAllMessages,
} = usePlayerMessages();
const markingAll = ref(false);
const deletingAll = ref(false);
const deleteAllConfirmVisible = ref(false);
const activeTab = computed<HubTab>(() => {
if (!inboxEnabled.value) return 'support';
return route.query.tab === 'support' ? 'support' : 'messages';
});
const unreadInList = computed(() => messages.value.filter((item) => !item.isRead).length);
const hasMessages = computed(() => listLoaded.value && messages.value.length > 0);
const showMessageActions = computed(
() => inboxEnabled.value && activeTab.value === 'messages' && auth.token && hasMessages.value,
);
function switchTab(tab: HubTab) {
if (!inboxEnabled.value || tab === activeTab.value) return;
router.replace({ path: '/messages', query: tab === 'support' ? { tab: 'support' } : {} });
}
async function onMarkAllRead() {
if (!unreadInList.value || markingAll.value) return;
markingAll.value = true;
try {
await markAllRead();
await refreshUnreadCount();
} finally {
markingAll.value = false;
}
}
function onDeleteAll() {
if (deletingAll.value || !messages.value.length) return;
deleteAllConfirmVisible.value = true;
}
async function confirmDeleteAll() {
if (deletingAll.value || !messages.value.length) return;
deletingAll.value = true;
try {
await deleteAllMessages();
await refreshUnreadCount();
deleteAllConfirmVisible.value = false;
} finally {
deletingAll.value = false;
}
}
function ensureSupportTabWhenDisabled() {
if (inboxEnabled.value || route.path !== '/messages') return;
if (route.query.tab === 'support') return;
void router.replace({ path: '/messages', query: { tab: 'support' } });
}
function goBack() {
router.back();
}
watch(inboxEnabled, (enabled, prev) => {
if (prev && !enabled) ensureSupportTabWhenDisabled();
});
watch(
() => route.query.tab,
() => {
if (inboxEnabled.value && activeTab.value === 'messages') void refreshUnreadCount();
},
);
onMounted(ensureSupportTabWhenDisabled);
onActivated(() => {
if (inboxEnabled.value) {
void refreshUnreadCount();
return;
}
ensureSupportTabWhenDisabled();
});
</script>
<template>
<div class="inbox-hub" :class="{ 'inbox-hub--tabs': inboxEnabled }">
<header class="page-header">
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack"></button>
<h1>{{ t(hubTitleKey) }}</h1>
</header>
<div v-if="inboxEnabled" class="hub-top-bar">
<nav class="hub-tabs" role="tablist">
<button
type="button"
role="tab"
class="hub-tab"
:class="{ active: activeTab === 'messages' }"
:aria-selected="activeTab === 'messages'"
@click="switchTab('messages')"
>
<span class="hub-tab-label">{{ t('inbox_hub.tab_messages') }}</span>
<span v-if="auth.token && unreadCount > 0" class="hub-tab-badge">
{{ unreadCount > 99 ? '99+' : unreadCount }}
</span>
</button>
<button
type="button"
role="tab"
class="hub-tab"
:class="{ active: activeTab === 'support' }"
:aria-selected="activeTab === 'support'"
@click="switchTab('support')"
>
{{ t('inbox_hub.tab_support') }}
</button>
</nav>
</div>
<div v-if="showMessageActions" class="message-actions">
<button
type="button"
class="action-btn"
:disabled="!unreadInList || markingAll"
@click="onMarkAllRead"
>
{{ t('messages.mark_all_read') }}
</button>
<button
type="button"
class="action-btn danger"
:disabled="deletingAll"
@click="onDeleteAll"
>
{{ t('messages.delete_all') }}
</button>
</div>
<MessageListPanel v-if="inboxEnabled" v-show="activeTab === 'messages'" />
<CustomerServicePanel v-if="activeTab === 'support'" />
<ConfirmDialog
v-model:visible="deleteAllConfirmVisible"
:title="t('messages.delete_all')"
:message="t('messages.delete_all_confirm')"
:confirm-text="t('messages.delete_all')"
danger
:loading="deletingAll"
@confirm="confirmDeleteAll"
/>
</div>
</template>
<style scoped>
.inbox-hub {
display: flex;
flex-direction: column;
min-height: 100%;
padding: 0 0 24px;
}
.inbox-hub--tabs {
margin: -12px -16px 0;
padding: max(12px, env(safe-area-inset-top, 0px)) 16px 0;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 14px;
border-bottom: 1px solid var(--border);
margin-bottom: 0;
}
.back-btn {
width: 36px;
height: 36px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--primary-dark, #002847);
}
.hub-top-bar {
margin: 0;
border-bottom: 1px solid var(--border);
}
.hub-tabs {
flex: 1;
display: flex;
gap: 0;
margin: 0;
min-width: 0;
}
.hub-tab {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 8px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-muted);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.hub-tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.hub-tab-label {
line-height: 1.2;
}
.hub-tab-badge {
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--primary);
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.message-actions {
display: flex;
gap: 8px;
padding: 10px 0 4px;
}
.action-btn {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border-active);
border-radius: 8px;
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.action-btn.danger {
border-color: rgba(220, 38, 38, 0.35);
background: rgba(220, 38, 38, 0.06);
color: #dc2626;
}
</style>

View File

@@ -164,7 +164,7 @@ function goRegister() {
align-items: center;
justify-content: flex-end;
padding: 34vh 20px calc(15vh + max(28px, env(safe-area-inset-bottom)));
background-color: var(--tertiary);
background: linear-gradient(160deg, #E8EDF2 0%, #F5F7FA 40%, #FFFFFF 100%);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
@@ -176,7 +176,11 @@ function goRegister() {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
padding: 20px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.field {
@@ -189,17 +193,18 @@ function goRegister() {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #0d0d0d;
color: var(--text);
background: #FFFFFF;
color: #1A1A2E;
font-size: 14px;
font-family: inherit;
}
.field-input:focus {
outline: none;
border-color: #555;
border-color: #0066CC;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
}
.inline-row {
@@ -215,7 +220,7 @@ function goRegister() {
label {
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
letter-spacing: 0.04em;
}
@@ -226,7 +231,7 @@ label {
padding: 0;
border: none;
background: transparent;
color: rgba(240, 216, 117, 0.75);
color: #005B9F;
font-size: 12px;
font-weight: 600;
cursor: pointer;
@@ -238,18 +243,18 @@ label {
padding: 0;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.5);
color: #6B7280;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.btn-forgot:active {
color: rgba(255, 255, 255, 0.75);
color: #003D6B;
}
.btn-mode-switch:active {
color: rgba(240, 216, 117, 0.95);
color: #003D6B;
}
.btn-login {
@@ -271,14 +276,14 @@ label {
padding: 8px 14px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.55);
color: #003D6B;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
color: #005B9F;
}
.btn-skip-light {
@@ -289,26 +294,26 @@ label {
padding: 8px 16px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.45);
color: #6B7280;
font-size: 13px;
font-weight: 300;
font-weight: 400;
cursor: pointer;
white-space: nowrap;
}
.btn-skip-light:active {
color: rgba(255, 255, 255, 0.65);
color: #003D6B;
}
.error {
color: var(--danger);
color: #DC2626;
font-size: 13px;
font-weight: 600;
}
.success {
margin: 0;
color: #6ee7a0;
color: #059669;
font-size: 12px;
font-weight: 600;
line-height: 1.4;

View File

@@ -36,6 +36,7 @@ interface Market {
lineKey?: string | null;
period: string;
lineValue?: string | number | null;
status?: string;
allowSingle?: boolean;
allowParlay?: boolean;
promoLabel?: string | null;
@@ -191,6 +192,28 @@ function marketLabel(market: Market | null | undefined) {
return market?.marketDisplayName?.trim() || market?.marketType || '';
}
function normalizeMarketStatus(status?: string) {
return (status ?? 'OPEN').toUpperCase();
}
function isMarketOpen(market: Market) {
return normalizeMarketStatus(market.status) === 'OPEN';
}
function marketStatusLabel(status?: string) {
const normalized = normalizeMarketStatus(status);
if (normalized === 'SUSPENDED') return t('bet.market_status_suspended');
if (normalized === 'CLOSED') return t('bet.market_status_closed');
return '';
}
function isMarketLocked(market: Market) {
if (!bettingOpen.value || !isMarketOpen(market)) return true;
const singleOk = market.allowSingle !== false;
const parlayOk = market.allowParlay !== false;
return !singleOk && !parlayOk;
}
function selectionLabel(sel: Selection) {
const parsedScore = parseScoreCode(sel.selectionCode, t);
if (parsedScore) return parsedScore.display;
@@ -224,8 +247,7 @@ function isSelected(id: string) {
}
function toggleSelection(sel: Selection, market: Market) {
if (!match.value || !bettingOpen.value) return;
if (market.allowSingle === false) return;
if (!match.value || isMarketLocked(market)) return;
if (!auth.token) {
goLogin();
return;
@@ -244,6 +266,7 @@ function toggleSelection(sel: Selection, market: Market) {
market.lineValue != null && market.lineValue !== ''
? parseFloat(String(market.lineValue))
: null,
allowSingle: market.allowSingle,
allowParlay: market.allowParlay,
});
slip.openDrawer();
@@ -388,24 +411,32 @@ function onPickSelection(selId: string, marketId: string) {
<MarketTypeTile
:label="marketLabel(market)"
:promo-label="market.promoLabel?.trim() || ''"
:status-label="bettingOpen && !isMarketOpen(market) ? marketStatusLabel(market.status) : ''"
:has-market="true"
:expanded="true"
/>
<div class="market-panel-wrap" :class="{ locked: !bettingOpen || market.allowSingle === false }">
<div class="market-panel-wrap" :class="{ locked: isMarketLocked(market) }">
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
<span
v-else-if="!isMarketOpen(market)"
class="market-status-tag"
:class="normalizeMarketStatus(market.status) === 'CLOSED' ? 'market-status-tag--closed' : 'market-status-tag--suspended'"
>
{{ marketStatusLabel(market.status) }}
</span>
<CorrectScorePanel
v-if="isCorrectScoreMarket(market.marketType)"
:market-type="market.marketType"
:selections="market.selections"
:locked="!bettingOpen || market.allowSingle === false"
:locked="isMarketLocked(market)"
:is-selected="isSelected"
@pick="onPickSelection($event, market.id)"
/>
<MarketSelectionsPanel
v-else
compact
:locked="!bettingOpen || market.allowSingle === false"
:locked="isMarketLocked(market)"
:line-value="market.lineValue"
:selections="market.selections"
:is-selected="isSelected"
@@ -422,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 {
@@ -440,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;
@@ -470,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;
@@ -483,75 +516,91 @@ 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 {
background: linear-gradient(180deg, rgba(240, 180, 41, 0.28) 0%, rgba(120, 80, 10, 0.92) 100%);
color: #ffe08a;
border-bottom: 1px solid rgba(240, 180, 41, 0.45);
border-left: 1px solid rgba(240, 180, 41, 0.45);
}
.market-status-tag--closed {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12) 0%, rgba(40, 40, 55, 0.95) 100%);
color: #8a9ab8;
border-bottom: 1px solid rgba(120, 140, 180, 0.3);
border-left: 1px solid rgba(120, 140, 180, 0.3);
}
.market-panel-wrap {
@@ -567,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;
@@ -579,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 {
@@ -605,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 {
@@ -616,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 {
@@ -633,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 {
@@ -643,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 {
@@ -674,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;
@@ -684,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;
@@ -710,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;
}
@@ -735,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);
@@ -781,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));
}
}
@@ -800,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) {
@@ -831,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;
@@ -861,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;
}
@@ -872,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 {
@@ -894,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;
@@ -911,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 {
@@ -933,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

@@ -0,0 +1,364 @@
<script setup lang="ts">
import { computed, onActivated, onMounted, ref, watch } from 'vue';
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 {
usePlayerMessages,
type DepositMessagePayload,
type BannerPromoPayload,
type PlayerMessage,
} from '../composables/usePlayerMessages';
import { useInboxFeature } from '../composables/useInboxFeature';
const route = useRoute();
const router = useRouter();
const { t, locale } = useI18n();
const { loadMessageDetail, markMessageRead, deleteMessage } = usePlayerMessages();
const { hubRoute } = useInboxFeature();
const messageId = computed(() => String(route.params.id ?? ''));
const message = ref<PlayerMessage | null>(null);
const loading = ref(false);
const error = ref(false);
const deleting = ref(false);
const deleteConfirmVisible = ref(false);
const depositPayload = computed(() => {
if (
!message.value ||
message.value.type === 'BANNER_PROMO' ||
message.value.type === 'ANNOUNCEMENT_PROMO'
) {
return null;
}
return (message.value.payload ?? null) as DepositMessagePayload | null;
});
const contentPromoId = computed(() => {
if (
message.value?.type !== 'BANNER_PROMO' &&
message.value?.type !== 'ANNOUNCEMENT_PROMO'
) {
return undefined;
}
return (message.value.payload as BannerPromoPayload | null)?.contentId;
});
function goBack() {
router.back();
}
function goList() {
router.push(hubRoute.value);
}
function viewContentPromo() {
if (!contentPromoId.value) return;
router.push(`/announcements/${contentPromoId.value}`);
}
function formatDate(createdAt?: string) {
if (!createdAt) return '';
return formatLocalMatchDateTime(createdAt, locale.value, { variant: 'full' });
}
function messageTitle(item: PlayerMessage) {
if (item.type === 'DEPOSIT_APPROVED') return t('messages.deposit_approved_title');
if (item.type === 'DEPOSIT_REJECTED') return t('messages.deposit_rejected_title');
if (item.type === 'BANNER_PROMO' || item.type === 'ANNOUNCEMENT_PROMO') return item.title;
return item.title;
}
function messageBody(item: PlayerMessage) {
const deposit = depositPayload.value;
if (item.type === 'DEPOSIT_APPROVED' && deposit?.orderNo) {
return t('messages.deposit_approved_body', {
orderNo: deposit.orderNo,
amount: formatMoney(deposit.amount ?? '0', locale.value),
approvedAmount: formatMoney(
deposit.approvedAmount ?? deposit.amount ?? '0',
locale.value,
),
});
}
if (item.type === 'DEPOSIT_REJECTED' && deposit?.orderNo) {
return t('messages.deposit_rejected_body', {
orderNo: deposit.orderNo,
amount: formatMoney(deposit.amount ?? '0', locale.value),
reason: deposit.rejectReason?.trim() || t('messages.no_reason'),
});
}
return item.body;
}
async function fetchDetail() {
if (!messageId.value) return;
loading.value = true;
error.value = false;
try {
message.value = await loadMessageDetail(messageId.value);
if (message.value && !message.value.isRead) {
await markMessageRead(messageId.value);
message.value = { ...message.value, isRead: true };
}
} catch {
error.value = true;
message.value = null;
} finally {
loading.value = false;
}
}
function onDelete() {
if (!messageId.value || deleting.value) return;
deleteConfirmVisible.value = true;
}
async function confirmDelete() {
if (!messageId.value || deleting.value) return;
deleting.value = true;
try {
await deleteMessage(messageId.value);
deleteConfirmVisible.value = false;
router.replace(hubRoute.value);
} finally {
deleting.value = false;
}
}
onMounted(() => {
void fetchDetail();
});
onActivated(() => {
void fetchDetail();
});
watch(messageId, () => {
void fetchDetail();
});
</script>
<template>
<div class="message-detail">
<header class="page-header">
<button type="button" class="back-btn" :aria-label="t('messages.back')" @click="goBack"></button>
<h1>{{ t('messages.detail_title') }}</h1>
<button
v-if="message"
type="button"
class="delete-header-btn"
:aria-label="t('messages.delete')"
:disabled="deleting"
@click="onDelete"
>
{{ t('messages.delete') }}
</button>
</header>
<div v-if="loading && !message" class="state">
<GoldSpinner :size="36" />
</div>
<div v-else-if="error || !message" class="empty">
<p>{{ t('messages.not_found') }}</p>
<button type="button" class="link-btn" @click="goList">{{ t('messages.view_all') }}</button>
</div>
<article v-else class="detail-article">
<span class="status-badge" :class="{ unread: !message.isRead }">
{{ message.isRead ? t('messages.status_read') : t('messages.status_unread') }}
</span>
<p v-if="message.createdAt" class="detail-date">{{ formatDate(message.createdAt) }}</p>
<h2 class="detail-title">{{ messageTitle(message) }}</h2>
<p class="detail-body">{{ messageBody(message) }}</p>
<div
v-if="message.type === 'DEPOSIT_REJECTED' && depositPayload?.rejectReason?.trim()"
class="reason-box"
>
<p class="reason-label">{{ t('messages.reject_reason') }}</p>
<p class="reason-text">{{ depositPayload.rejectReason }}</p>
</div>
<button
v-if="contentPromoId"
type="button"
class="action-btn"
@click="viewContentPromo"
>
{{ t('messages.content_promo_view') }}
</button>
<button
v-if="depositPayload?.depositOrderId"
type="button"
class="action-btn"
@click="router.push('/wallet/recharge/history')"
>
{{ t('messages.view_recharge_history') }}
</button>
</article>
<ConfirmDialog
v-model:visible="deleteConfirmVisible"
:title="t('messages.delete')"
:message="t('messages.delete_confirm')"
:confirm-text="t('messages.delete')"
danger
:loading="deleting"
@confirm="confirmDelete"
/>
</div>
</template>
<style scoped>
.message-detail {
min-height: 100%;
padding: 0 0 24px;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
padding: 0 0 14px;
border-bottom: 1px solid var(--border);
}
.back-btn {
width: 36px;
height: 36px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
color: var(--primary);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.page-header h1 {
margin: 0;
flex: 1;
font-size: 18px;
font-weight: 700;
color: var(--primary-dark, #002847);
}
.delete-header-btn {
border: 1px solid rgba(220, 38, 38, 0.35);
border-radius: 8px;
padding: 6px 10px;
background: rgba(220, 38, 38, 0.06);
color: #dc2626;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.delete-header-btn:disabled {
opacity: 0.45;
}
.status-badge {
display: inline-block;
margin-bottom: 8px;
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
background: rgba(0, 0, 0, 0.05);
}
.status-badge.unread {
color: var(--primary);
background: rgba(0, 61, 107, 0.08);
}
.state,
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 16px;
color: var(--text-muted);
}
.link-btn {
border: 1px solid var(--border-active);
border-radius: 8px;
padding: 8px 14px;
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.detail-article {
padding: 18px 0 0;
}
.detail-date {
margin: 0 0 10px;
font-size: 12px;
color: var(--text-muted);
}
.detail-title {
margin: 0 0 12px;
font-size: 20px;
font-weight: 700;
line-height: 1.45;
color: var(--primary-dark, #002847);
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #374151;
}
.reason-box {
margin-top: 18px;
padding: 14px;
border-radius: 10px;
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: #dc2626;
font-weight: 700;
}
.reason-text {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: #b91c1c;
}
.action-btn {
margin-top: 24px;
width: 100%;
padding: 11px 14px;
border: 1px solid var(--border-active);
border-radius: 8px;
background: rgba(0, 61, 107, 0.06);
color: var(--primary);
font-size: 14px;
font-weight: 700;
cursor: pointer;
display: block;
}
</style>

View File

@@ -176,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 {
@@ -194,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;
@@ -217,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

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

@@ -142,61 +142,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="/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">
@@ -274,7 +268,7 @@ const balanceAmountClass = computed(() => {
}
.profile-page {
padding: 8px 0 12px;
padding: 6px 0 10px;
}
.loading-state {
@@ -294,7 +288,7 @@ const balanceAmountClass = computed(() => {
}
.error-text {
color: var(--text-muted);
color: #6B7280;
font-size: 14px;
font-weight: 600;
}
@@ -302,28 +296,26 @@ 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);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.wallet-banner::after {
@@ -332,7 +324,6 @@ const balanceAmountClass = computed(() => {
inset: 0;
z-index: 1;
border-radius: inherit;
box-shadow: inset 0 0 14px rgba(0, 0, 0, 0.22);
pointer-events: none;
}
@@ -344,9 +335,6 @@ const balanceAmountClass = computed(() => {
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;
}
@@ -357,10 +345,10 @@ const balanceAmountClass = computed(() => {
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%
135deg,
#003D6B 0%,
#005B9F 50%,
#003D6B 100%
);
}
@@ -370,7 +358,7 @@ const balanceAmountClass = computed(() => {
z-index: 3;
display: flex;
flex-direction: column;
padding: 14px 16px 12px;
padding: 10px 14px 10px;
line-height: 1.3;
-webkit-font-smoothing: antialiased;
}
@@ -391,8 +379,8 @@ const balanceAmountClass = computed(() => {
}
.brand-logo {
width: 22px;
height: 22px;
width: 18px;
height: 18px;
flex-shrink: 0;
object-fit: contain;
}
@@ -407,17 +395,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;
}
@@ -428,9 +415,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 {
@@ -440,20 +426,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 {
@@ -468,8 +453,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 {
@@ -478,39 +463,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;
}
@@ -520,11 +504,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;
}
@@ -532,16 +515,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;
}
@@ -549,16 +609,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 {
@@ -566,41 +626,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;
}
@@ -615,11 +649,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 {
@@ -629,11 +659,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;
@@ -651,8 +681,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 {
@@ -666,43 +696,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

@@ -411,23 +411,24 @@ function auditStepCount(order: DepositOrder) {
<style scoped>
.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;
@@ -437,69 +438,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;
}
@@ -510,16 +508,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;
}
@@ -532,7 +530,7 @@ function auditStepCount(order: DepositOrder) {
background: none;
text-align: right;
font-size: 11px;
color: var(--primary-light);
color: #003D6B;
font-weight: 700;
cursor: pointer;
}
@@ -550,7 +548,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;
@@ -560,7 +558,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;
@@ -573,12 +571,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 {
@@ -590,7 +588,7 @@ function auditStepCount(order: DepositOrder) {
border: none;
border-radius: 50%;
background: transparent;
color: #777;
color: #6B7280;
font-size: 13px;
cursor: pointer;
line-height: 1;
@@ -600,15 +598,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 {
@@ -622,13 +620,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;
}
@@ -643,13 +641,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;
}
@@ -657,12 +655,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;
}
@@ -670,12 +668,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;
}
@@ -688,14 +686,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;
}
@@ -723,7 +721,7 @@ function auditStepCount(order: DepositOrder) {
width: 5px;
height: 5px;
border-radius: 50%;
background: #555;
background: #E5E7EB;
flex-shrink: 0;
}
@@ -732,7 +730,7 @@ function auditStepCount(order: DepositOrder) {
width: 1px;
min-height: 10px;
margin: 3px 0;
background: #2a2a2a;
background: #E5E7EB;
}
.audit-step-body {
@@ -755,13 +753,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;
@@ -770,7 +768,7 @@ function auditStepCount(order: DepositOrder) {
.audit-step-actor {
margin: 2px 0 0;
font-size: 11px;
color: #999;
color: #6B7280;
line-height: 1.4;
}
@@ -778,14 +776,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;
}
@@ -803,35 +801,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

@@ -368,9 +368,9 @@ onMounted(fetchMethods);
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; }
@@ -380,24 +380,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 {
@@ -405,19 +405,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;
@@ -433,15 +433,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 {
@@ -450,9 +444,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;
@@ -461,7 +455,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;
}
@@ -470,7 +464,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 {
@@ -478,7 +472,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,
@@ -498,10 +492,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;
@@ -520,8 +513,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;
}
@@ -531,9 +523,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;
@@ -548,7 +540,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 {
@@ -570,15 +562,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;
}
@@ -587,87 +578,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

@@ -227,7 +227,7 @@ const fieldError = () => {
align-items: center;
justify-content: flex-end;
padding: 30vh 16px calc(10vh + max(20px, env(safe-area-inset-bottom)));
background-color: var(--tertiary);
background: linear-gradient(160deg, #E8EDF2 0%, #F5F7FA 40%, #FFFFFF 100%);
background-size: cover;
background-position: center top;
background-repeat: no-repeat;
@@ -240,7 +240,11 @@ const fieldError = () => {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
padding: 20px;
background: #FFFFFF;
border: 1px solid #E5E7EB;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.login-form.is-busy {
@@ -257,7 +261,7 @@ const fieldError = () => {
justify-content: center;
gap: 10px;
border-radius: inherit;
background: rgba(8, 8, 8, 0.72);
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(2px);
pointer-events: all;
}
@@ -266,7 +270,7 @@ const fieldError = () => {
margin: 0;
font-size: 13px;
font-weight: 700;
color: rgba(240, 216, 117, 0.95);
color: #003D6B;
letter-spacing: 0.04em;
}
@@ -281,7 +285,7 @@ const fieldError = () => {
margin: 0 0 2px;
font-size: 16px;
font-weight: 800;
color: #fff;
color: #1A1A2E;
text-align: center;
}
@@ -297,7 +301,7 @@ const fieldError = () => {
label {
font-size: 10px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
letter-spacing: 0.03em;
}
@@ -305,28 +309,29 @@ label {
.optional-tag {
font-size: 10px;
font-weight: 500;
color: rgba(255, 255, 255, 0.4);
color: #6B7280;
}
.field-input {
width: 100%;
height: 36px;
padding: 0 10px;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #0d0d0d;
color: var(--text);
background: #FFFFFF;
color: #1A1A2E;
font-size: 14px;
font-family: inherit;
}
.field-input::placeholder {
color: rgba(255, 255, 255, 0.32);
color: rgba(107, 114, 128, 0.6);
}
.field-input:focus {
outline: none;
border-color: #555;
border-color: #0066CC;
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.15);
}
.inline-row {
@@ -344,10 +349,10 @@ label {
flex-shrink: 0;
height: 36px;
padding: 0 10px;
border: 1px solid var(--border);
border: 1px solid #E5E7EB;
border-radius: 6px;
background: #141414;
color: var(--text);
background: #F5F7FA;
color: #003D6B;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
@@ -381,14 +386,14 @@ label {
padding: 8px 14px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.55);
color: #003D6B;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-skip:active {
color: rgba(255, 255, 255, 0.75);
color: #005B9F;
}
.btn-skip-light {
@@ -399,20 +404,20 @@ label {
padding: 8px 16px;
border: none;
background: transparent;
color: rgba(255, 255, 255, 0.45);
color: #6B7280;
font-size: 13px;
font-weight: 300;
font-weight: 400;
cursor: pointer;
white-space: nowrap;
}
.btn-skip-light:active {
color: rgba(255, 255, 255, 0.65);
color: #003D6B;
}
.error {
margin: 0;
color: var(--danger);
color: #DC2626;
font-size: 12px;
font-weight: 600;
line-height: 1.3;

View File

@@ -238,7 +238,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;
@@ -273,16 +273,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: #FFFFFF;
background: #003D6B;
border-color: #003D6B;
}
.state {
@@ -291,7 +291,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;
@@ -312,8 +312,8 @@ const pullIndicatorStyle = () => ({
font-size: 14px;
padding: 12px 16px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
border-bottom: 1px solid #E5E7EB;
background: #FFFFFF;
cursor: pointer;
}
@@ -322,7 +322,7 @@ const pullIndicatorStyle = () => ({
}
.tx-row:active {
background: rgba(255, 255, 255, 0.03);
background: #F5F7FA;
}
.tx-main {
@@ -341,7 +341,7 @@ const pullIndicatorStyle = () => ({
.tx-summary {
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
@@ -355,11 +355,11 @@ const pullIndicatorStyle = () => ({
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; }
.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: 700; color: #1A1A2E; }
.pos { color: #003D6B; font-weight: 800; font-size: 15px; }
.neg { color: #DC2626; font-weight: 700; }
.tx-time { font-size: 11px; color: #6B7280; }
.tx-arrow { font-size: 16px; color: #6B7280; font-weight: 700; line-height: 1; }
.sentinel {
height: 1px;
@@ -374,11 +374,11 @@ 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;
}
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
.empty { text-align: center; color: #6B7280; padding: 40px 16px; font-weight: 600; }
</style>

View File

@@ -238,7 +238,7 @@ function goCashbackDetail() {
.back-btn {
background: none;
border: none;
color: var(--primary-light, #d4af37);
color: #003D6B;
font-size: 15px;
font-weight: 700;
padding: 4px 0 8px;
@@ -251,7 +251,7 @@ function goCashbackDetail() {
.state {
text-align: center;
padding: 60px 20px;
color: #666;
color: #6B7280;
font-size: 14px;
font-weight: 600;
}
@@ -265,18 +265,19 @@ function goCashbackDetail() {
gap: 6px;
margin-bottom: 12px;
text-align: center;
background: #141414;
border: 1px solid #222;
background: #FFFFFF;
border: 1px solid #E5E7EB;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.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(0, 61, 107, 0.2);
background: linear-gradient(135deg, rgba(0, 61, 107, 0.04), #FFFFFF);
}
.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(220, 38, 38, 0.2);
background: linear-gradient(135deg, rgba(220, 38, 38, 0.04), #FFFFFF);
}
.hero-type {
@@ -284,13 +285,13 @@ function goCashbackDetail() {
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #888;
color: #6B7280;
}
.hero-summary {
font-size: 13px;
font-weight: 600;
color: #bbb;
color: #6B7280;
max-width: 100%;
word-break: break-all;
}
@@ -301,27 +302,28 @@ function goCashbackDetail() {
line-height: 1.1;
}
.hero.pos .hero-amount { color: var(--primary-light); }
.hero.neg .hero-amount { color: var(--danger); }
.hero.pos .hero-amount { color: #003D6B; }
.hero.neg .hero-amount { color: #DC2626; }
.hero-time {
font-size: 11px;
color: #555;
color: #6B7280;
font-weight: 600;
}
.section {
background: #141414;
border: 1px solid #222;
border-radius: 12px;
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);
}
.section-title {
font-size: 12px;
font-weight: 800;
color: #888;
color: #6B7280;
letter-spacing: 0.04em;
margin-bottom: 10px;
}
@@ -338,18 +340,18 @@ function goCashbackDetail() {
align-items: flex-start;
gap: 12px;
font-size: 13px;
color: #aaa;
color: #6B7280;
}
.sum-row span:last-child {
color: #f0f0f0;
color: #1A1A2E;
font-weight: 700;
text-align: right;
word-break: break-all;
}
.pos { color: var(--primary-light) !important; }
.neg { color: var(--danger) !important; }
.pos { color: #003D6B !important; }
.neg { color: #DC2626 !important; }
.mono {
font-family: ui-monospace, monospace;
@@ -366,9 +368,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 #003D6B;
background: rgba(0, 61, 107, 0.04);
color: #003D6B;
font-size: 13px;
font-weight: 700;
cursor: pointer;
@@ -377,7 +379,7 @@ function goCashbackDetail() {
.tx-id {
font-family: ui-monospace, monospace;
font-size: 12px;
color: #ccc;
color: #1A1A2E;
word-break: break-all;
line-height: 1.5;
}

View File

@@ -141,7 +141,7 @@ const pullIndicatorStyle = () => ({
<style scoped>
.wallet-page {
padding-bottom: 24px;
padding-bottom: 16px;
}
.pull-indicator {
@@ -158,7 +158,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;
@@ -176,11 +176,11 @@ const pullIndicatorStyle = () => ({
display: block;
width: 100%;
text-align: left;
font-size: 14px;
padding: 8px 12px;
font-size: 13px;
padding: 6px 10px;
border: none;
border-bottom: 1px solid var(--border);
background: transparent;
border-bottom: 1px solid #E5E7EB;
background: #FFFFFF;
cursor: pointer;
}
@@ -189,7 +189,7 @@ const pullIndicatorStyle = () => ({
}
.tx-row:active {
background: rgba(255, 255, 255, 0.03);
background: #F5F7FA;
}
.tx-main {
@@ -208,7 +208,7 @@ const pullIndicatorStyle = () => ({
.tx-summary {
font-size: 11px;
color: var(--text-muted);
color: #6B7280;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
@@ -219,31 +219,31 @@ const pullIndicatorStyle = () => ({
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
margin-top: 3px;
}
.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; }
.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: 700; color: #1A1A2E; }
.pos { color: #003D6B; font-weight: 800; font-size: 14px; }
.neg { color: #DC2626; font-weight: 700; }
.tx-time { font-size: 11px; color: #6B7280; }
.tx-arrow { font-size: 16px; color: #6B7280; font-weight: 700; line-height: 1; }
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 4px 2px;
margin-top: -8px;
padding: 0 4px 1px;
margin-top: -6px;
}
.top-bar .more-link.secondary {
color: #f0b90b;
color: #005B9F;
}
.more-link {
background: none;
border: none;
color: var(--primary-light);
color: #003D6B;
font-size: 12px;
font-weight: 600;
padding: 2px 6px;
@@ -256,5 +256,5 @@ const pullIndicatorStyle = () => ({
opacity: 1;
}
.empty { text-align: center; color: var(--text-muted); padding: 40px 16px; font-weight: 600; }
.empty { text-align: center; color: #6B7280; padding: 28px 14px; font-weight: 600; }
</style>

View File

@@ -9,6 +9,17 @@ server {
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
# 带 hash 的静态资源可长期缓存index.html 禁止缓存,避免发版后引用旧 chunk
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
location /api/ {
proxy_pass http://api:3000;
proxy_http_version 1.1;

View File

@@ -10,6 +10,16 @@ server {
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
location /api/ {
set $api_upstream api:3000;
proxy_pass http://$api_upstream;

View File

@@ -226,55 +226,226 @@ pnpm docker:ps
---
## 八、后续更新部署
## 八、推荐发版流程:本地打包 → 上传 → 线上更新
**推荐:先删旧代码再解压新 zip**(避免 `packages/shared/public/球员` 等中文目录残留导致 Vite 构建失败)
**日常更新推荐走这条链路**,不在服务器上编译,省 CPU/内存,发版包可重复部署
推荐主流程是:本地或构建机生成版本化镜像包 → 上传 tar 与 manifest → 服务器执行 `deploy-update.sh --images ... --tag ...`。详细步骤见:[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)(脚本位于 `docs/docker/build-and-export-images.ps1` / `build-and-export-images.sh`)。
```text
本地 Windows/Mac 宝塔/SCP 上传 服务器终端
───────────────── ─────────────── ─────────────────
checkout 目标分支 → 只传 tar + manifest → deploy-update.sh
build 三端镜像 到 /www/wwwroot/thebet365 自动备份+迁移+重启
导出 .tar + .manifest 不要覆盖 .env.docker
```
### 方式 A服务器直接拉代码并构建
镜像构建细节见:[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)。
---
### 步骤 1本地打包镜像
#### 1.1 前置条件
- 已安装 **Docker Desktop**Windows/Mac或 Linux Docker
- 在项目根目录,且已 `git checkout` 到要发布的分支(如 `main``theme-4`
#### 1.2 确认环境文件
本地需有 `.env.docker`(可从 `.env.docker.example` 复制)。构建 **admin** 镜像时会读取其中的 `VITE_PLAYER_URL`(玩家站公网地址,用于邀请链接)。若玩家域名有变,**改 `.env.docker` 后需重新打包 admin**。
#### 1.3 执行构建脚本
**Windows推荐 CMD**
```bat
cd C:\path\to\thebet365
docs\docker\build-and-export-images.bat --tag latest
```
带版本号(便于回滚追溯):
```bat
docs\docker\build-and-export-images.bat --tag v20260618
```
**Linux / macOS / Git Bash**
```bash
cd /path/to/thebet365
chmod +x docs/docker/build-and-export-images.sh
./docs/docker/build-and-export-images.sh --tag latest
```
首次或发版建议不加 `--use-cache`(默认全量构建)。仅重新导出已有镜像时:
```bat
docs\docker\build-and-export-images.bat --export-only --tag latest
```
#### 1.4 构建产物(在项目根目录)
| 文件 | 说明 |
|------|------|
| `thebet365-images-<tag>.tar` | 含 `api` / `player` / `admin` 三个镜像,约 200300 MB |
| `thebet365-images-<tag>.manifest.txt` | 记录 tag、构建时间、`git_commit`、镜像 ID、tar SHA-256 |
示例:`thebet365-images-latest.tar``thebet365-images-latest.manifest.txt`
> 这两个文件已在 `.gitignore` 中,**不要提交到 Git**。
---
### 步骤 2上传到服务器
#### 2.1 上传目标目录
服务器项目目录,例如:
```text
/www/wwwroot/thebet365
```
#### 2.2 本次更新需要上传的文件
| 文件 | 是否必传 |
|------|----------|
| `thebet365-images-<tag>.tar` | **必传** |
| `thebet365-images-<tag>.manifest.txt` | 建议传(便于核对版本) |
可用 **宝塔 → 文件 → 上传**,或 SCP
```bash
scp thebet365-images-latest.tar thebet365-images-latest.manifest.txt \
root@你的服务器IP:/www/wwwroot/thebet365/
```
#### 2.3 不要覆盖的文件
| 文件/目录 | 说明 |
|-----------|------|
| **`.env.docker`** | 生产密钥、端口、域名配置;**保留服务器上原有文件** |
| `postgres_data` 等 Docker 卷 | 数据库与上传文件,不在文件管理里替换 |
服务器目录里应**早已存在**(首次部署时上传过):`docker-compose.prod.yml``scripts/``docker/` 等。仅换镜像时**不必**重传整份代码。
若部署脚本有更新(如 `scripts/deploy-lib.sh`),可单独上传覆盖 `scripts/` 目录。
---
### 步骤 3服务器执行更新脚本
SSH 或 **宝塔 → 终端**,进入项目目录:
```bash
cd /www/wwwroot/thebet365
./scripts/deploy-update.sh --pull
chmod +x scripts/*.sh
./scripts/deploy-update.sh --images thebet365-images-latest.tar --tag latest
```
### 方式 B上传 zip 后在服务器构建
`--tag` 必须与打包时一致(上例为 `latest`;若本地用了 `--tag v20260618`,这里也要写 `v20260618`)。
保留原来的 `.env.docker`,替换代码后执行:
#### 脚本会自动完成(按顺序)
1. 检查 `.env.docker`(缺少密钥会报错;示例密码仅**警告**,不阻断)
2. 启动并等待 PostgreSQL / Redis 就绪
3. **更新前备份**`./backups/thebet365-db-pre-update-<时间>.sql.gz``thebet365-uploads-pre-update-<时间>.tar.gz`
4. `docker load -i` 导入镜像包
5. 用新 API 镜像执行 `prisma migrate deploy`(数据库结构变更)
6. 重启/替换 `api``player``admin` 容器
7. 等待健康检查通过,执行 `prisma migrate status`
8. 将本次发布写入 `.deploy/current-release.env`(含备份路径)
#### `.env.docker` 会被改什么?
- **不会**整文件覆盖,**不会**改 `JWT_SECRET``POSTGRES_PASSWORD`
- 部署结束时可能只更新一行:`IMAGE_TAG=<本次 tag>`
#### 首次用镜像包部署(新机器)
若服务器从未部署过,用首次脚本:
```bash
cd /www/wwwroot/thebet365
./scripts/deploy-update.sh
./scripts/deploy-first.sh --images thebet365-images-latest.tar --tag latest
```
### 方式 C上传已构建镜像包
---
### 步骤 4验证是否成功
```bash
cd /www/wwwroot/thebet365
./scripts/deploy-update.sh --images thebet365-images-v1.2.3.tar --tag v1.2.3
# 容器状态api / player / admin 应为 healthy
docker compose -f docker-compose.prod.yml --env-file .env.docker ps
# 迁移是否全部应用
docker compose -f docker-compose.prod.yml --env-file .env.docker exec api \
sh -c 'cd /app/apps/api && npx prisma migrate status'
# 本次发布记录与备份路径
cat .deploy/current-release.env
# 今天是否生成了新备份
ls -lh backups/ | tail -5
# API 日志(无报错即可)
docker compose -f docker-compose.prod.yml --env-file .env.docker logs --tail=50 api
```
更新脚本默认会:
浏览器:玩家站、管理站各访问一次;必要时强刷或清 CDN 缓存。
- 先备份 PostgreSQL 与 uploads 到 `./backups/`,并生成 `.sha256`
- 构建或加载指定 tag 的新镜像
- 使用新 API 镜像执行 `prisma migrate deploy`
- 启动/替换 API、玩家端、管理端容器
- 等待 API、玩家端、管理端健康检查通过
- 执行 `prisma migrate status` 检查数据库迁移状态
- 将当前发布写入 `.deploy/current-release.env`,并保留上一次发布到 `.deploy/previous-release.env`
---
除非已经手工确认有其他备份,否则不要使用 `--no-backup`
### 步骤 5部署后管理端配置镜像不会自动开启
若本次更新含站内邮箱、员工菜单等新功能,需在**管理后台**手动配置:
1. **员工管理** → 给员工勾选可见菜单(`visibleMenus`
2. **内容管理** → 开启 Inbox站内邮箱及充值/Banner/公告通知开关
3. 验证:充值审核后玩家收到站内信、侧栏充值待审角标、在线人数等
---
### 备份说明
| 项目 | 说明 |
|------|------|
| **何时备份** | 执行 `deploy-update.sh` 时,在**加载新镜像、跑迁移之前** |
| **备份内容** | 更新前一刻的 PostgreSQL 全库 + `uploads` 用户上传文件 |
| **存放位置** | `/www/wwwroot/thebet365/backups/` |
| **文件命名** | `thebet365-db-pre-update-YYYYMMDD-HHMMSS.sql.gz``thebet365-uploads-pre-update-....tar.gz` |
| **记录位置** | `.deploy/current-release.env` 中的 `db_backup=``uploads_backup=` |
手动备份(不更新时也可执行):
```bash
./scripts/backup-db.sh
./scripts/backup-prod.sh --prefix manual
```
**恢复数据库**(仅出问题时):先 `stop api`,再将 `.sql.gz` 导入 postgres最后 `start api`。项目无一键恢复脚本,需手工操作;详见下方回滚说明。
---
### 其他更新方式(备选)
| 方式 | 适用场景 | 命令 |
|------|----------|------|
| **A服务器拉代码构建** | 服务器性能足够、不用传 tar | `./scripts/deploy-update.sh --pull` |
| **B上传 zip 后服务器构建** | 无 Git、在服务器编译 | 替换代码后 `./scripts/deploy-update.sh` |
方式 B 上传代码时**保留** `.env.docker`;若用 zip 覆盖,注意清理旧的中文目录 `packages/shared/public/球员`(见第九节故障排查)。
---
### 回滚应用镜像
```bash
cd /www/wwwroot/thebet365
./scripts/rollback.sh --to v1.2.2
./scripts/rollback.sh --to <旧tag>
```
回滚脚本只切换 `api` / `player` / `admin` 镜像 tag不自动恢复数据库。若新版本包含不可逆迁移或已写入不兼容数据,需要先按 `backups/` 中的 `.sql.gz` 备份手工恢复 PostgreSQL执行镜像回滚。
`rollback.sh` **只切换** `api` / `player` / `admin` 镜像 tag**不自动恢复数据库**。若新版本已执行不可逆迁移,需先从 `backups/`选取 `pre-update` `.sql.gz` 手工恢复 PostgreSQL再回滚镜像
除非已确认另有备份,否则不要使用 `--no-backup` 跳过自动备份。
---
@@ -329,10 +500,10 @@ docker compose -f docker-compose.prod.yml --env-file .env.docker build --no-cach
## 十、与本地开发的区别
| 场景 | 命令 |
| 场景 | 命令 / 文档 |
|------|------|
| 本地开发(仅 DB 用 Docker | `docker compose up -d` + `pnpm dev` |
| 生产首次部署 | `./scripts/deploy-first.sh` |
| 生产后续更新 | `./scripts/deploy-update.sh` |
| 生产后续更新(推荐) | 本地打包 → 上传 tar → `./scripts/deploy-update.sh --images ... --tag ...`(见第八节) |
相关文档:[项目启动指南.md](./项目启动指南.md)

View File

@@ -0,0 +1,281 @@
# 管理端页面切换性能分析与优化任务
> 分析日期2026-06-18
> 范围:`apps/admin` 侧边栏切换路由时的响应速度(非玩家端)。
---
## 任务清单
- [x] **measure-baseline**DevTools 验收步骤见下文「九、度量验收」目标guard 无阻塞 /me、切回列表无重复全量 API、hover 预取后 RouteChanged < 100ms
- [x] **instant-nav-phase1**KeepAlive name 对齐、非阻塞 guard、侧边栏 chunk 预取2026-06-18
- [x] **instant-nav-phase2**`useStaleListLifecycle` + 列表页 v-loading 壳、AgentManager 按 tab 延迟 API2026-06-18
- [x] **keepalive-layout**`ManageLayout``RouterView` 增加 `KeepAlive` + 列表页 `defineOptions({ name })`2026-06-18
- [x] **list-stale-cache**:高频列表页改 `onActivated` + stale-while-revalidate避免 remount 全量 refetch2026-06-18
- [x] **fix-deposit-tabs**`DepositManage``v-if``v-show`2026-06-18
- [ ] **lighten-agent-manager**`AgentManager` 挂载 API 合并或按 tab 延迟加载onActivated + 按 tab 延迟已做,拆分 SFC 待后续)
- [x] **guard-session**`beforeEach` 去阻塞式 `ensureStaffSession`TTL 内同步快速路径);`api` 拦截器减少 per-request reconcile2026-06-18
- [x] **bundle-i18n-ep**Element Plus 按需引入 + 落地 `split-i18n` + `App.vue` CSS 瘦身2026-06-18
---
## 现象定义
用户感知的「切换慢」通常包含三段延迟叠加:
```mermaid
sequenceDiagram
participant User
participant RouterGuard
participant ChunkLoader
participant PageView
participant API
User->>RouterGuard: 点击侧边栏
RouterGuard->>RouterGuard: ensureStaffSession (可能 HTTP)
RouterGuard->>ChunkLoader: 动态 import 页面 chunk
ChunkLoader->>PageView: mount 组件
PageView->>API: onMounted 并发拉列表
API-->>PageView: 渲染表格
PageView-->>User: 页面可交互
```
---
## 一、架构结论
| 层级 | 现状 | 对切页的影响 |
|------|------|-------------|
| 路由 | 全部 lazy load`apps/admin/src/router/index.ts` | 首次进入某页需下载 chunk |
| 布局 | `ManageLayout.vue` 常驻 | 壳层不重载,合理 |
| **页面缓存** | **全项目无 `<KeepAlive>`** | **切走即销毁,回来必 remount + 重拉数据** |
| 数据层 | 无 Pinia仅少数 composable 有模块级缓存 | 绝大多数列表页无跨访问缓存 |
| 首屏 bundle | Element Plus 全量 + 三语 i18n 打进主包 | 影响首访/冷启动,对已登录切页影响次之 |
**最大根因:没有 KeepAlive + 页面 `onMounted` 全量 refetch。**
---
## 二、切页时实际发生什么
### 2.1 路由守卫(每条鉴权路由)
`apps/admin/src/router/index.ts``beforeEach`
- 有 token 时 **`await ensureStaffSession()`**
- `apps/admin/src/utils/session-hydrate.ts` 有 60s TTL过期后会 **`GET /manage/auth/me`****阻塞导航完成**
- 访问 smoke-tests 路由时额外 `await ensureLoaded()`
### 2.2 页面生命周期(无缓存)
`ManageLayout.vue` 内为裸 `<RouterView />`
- 旧页面 **unmount**
- 新页面 chunk **动态 import****mount**
- 典型模式:`onMounted(load)` / 顶层 `void load()`
**Dashboard 子页** 体验较好:`useAdminDashboard.ts` 模块级 `stats` 缓存,同 session 内切 `/``/dashboard/players` 可跳过 API`HomeEntry` 仍可能闪 boot 屏)。
### 2.3 布局层常驻副作用
`ManageLayout.vue` `onMounted`
- `useDepositPendingCount`:立即请求 + **每 30s 轮询** `pending-count`
- `useSmokeTestsAllowed`:一次性权限探测
不直接阻塞切页,但增加后台并发请求。
### 2.4 每个 API 请求的同步开销
`apps/admin/src/api.ts` 请求拦截器对每个请求调用 `reconcileStaffSessionFromToken()`JWT decode + localStorage。列表页 mount 时常 **并发 310 个请求**,同步开销被放大。
---
## 三、按影响排序的瓶颈清单
### P0 — 切换体验(每次切页都痛)
**1. 无 KeepAlive列表页反复 remount + refetch**
受影响页面(模式相同):
- `Bets.vue``Cashback.vue`
- `Matches.vue``MatchesOutrights.vue`
- `DepositOrders.vue``StaffManage.vue`
- `Contents.vue``FinanceLogs.vue`
**2. 重型页面挂载 API burst**
`AgentManager.vue``/users`)— 约 2900 行 SFC`onMounted` 并行:
- `GET /admin/users/page-init`
- `GET /admin/users`(全量玩家)
- `GET /admin/agents?level=1`
- page-init 后再按层级 **N+1** 拉子代理
每次从其他页回到 `/users` 都会重复上述 burst。
**3. Tab 用 `v-if` 导致子页销毁**
`DepositManage.vue`
```vue
<DepositOrders v-if="activeTab === 'orders'" />
<PaymentMethods v-if="activeTab === 'methods'" />
```
同页内切换 tab 也会 destroy + `onMounted(fetchList)`
**4. Matches 展开面板扇出请求**
`LeagueMatchesPanel.vue` `watch(..., { immediate: true })`:恢复 session 展开最多 3 个联赛时,**最多 3 路** `GET /admin/matches`;折叠再展开会 remount 重拉。
### P1 — 间歇性卡顿(特定路径 / 时间)
**5. Session hydrate 阻塞导航**
60s TTL 过期后,**每次切页**先等 `/manage/auth/me`。弱网或后端慢时,侧边栏点击后「卡住」数秒。
**6. HomeEntry 回 Dashboard 闪屏**
`HomeEntry.vue``onBeforeMount` 再次 `ensureStaffSession` + `booting` 全屏 loadingrouter 已做过 hydrate
**7. 首次进入大 chunk 的 JS 解析**
| 页面 | 风险 |
|------|------|
| `AgentManager.vue` | 巨型 SFC + 多子组件 |
| `Settlement.vue` | ~1500 行 + async echarts |
| `Contents.vue` | `ContentRichEditor.vue` |
### P2 — 首屏 / 冷启动
**8. Element Plus 全量注册**
`main.ts` 全量 `app.use(ElementPlus)` + `element-plus/dist/index.css`,无按需引入。
**9. i18n 三语未真正拆包**
`admin-messages.ts` 仍静态 import zh/en/ms 全文;`split-i18n.mjs` 未落地,首屏携带全部语言文案。
**10. App.vue 全局 CSS ~1900 行**
无 scoped 的暗色/浅色双套 Element 覆盖,与全量 EP CSS 叠加。
---
## 四、问题分层矩阵
| 症状 | 最可能原因 | 验证方式 |
|------|-----------|----------|
| 任意页切回上一页都慢 | 无 KeepAlive + onMounted refetch | Network切回同页重复相同 API |
| 仅 `/users` 特别慢 | AgentManager 多路并行 API + 大 chunk | mount 时 3+ 请求Performance 看 JS |
| 偶尔点菜单无反应数秒 | `ensureStaffSession` 阻塞 | 切页瞬间是否有 `/manage/auth/me` |
| 充值页 tab 切换慢 | DepositManage `v-if` | 切 tab 是否重复 `deposit-orders` 请求 |
| 首次进某页慢、之后再进仍慢 | 大 chunk + 仍无缓存 | 对比首次/二次 Network |
| 整体首次打开就慢 | EP 全量 + i18n 三语 + 全局 CSS | `pnpm --filter @thebet365/admin build:analyze` |
---
## 五、优化路径(分阶段)
### 阶段 A — 切页体验12 天,收益最大)
1. `ManageLayout.vue``<RouterView>` 外包 `<KeepAlive :max="8">`,列表页 `defineOptions({ name })`
2. 列表页改 `onActivated` + stale-while-revalidate有缓存先展示后台静默刷新
3. `DepositManage.vue``v-if``v-show` 或 keep-alive 两个 tab
4. `HomeEntry.vue`:去掉重复 hydrate / 仅首次 boot
### 阶段 B — 重型页与 API23 天)
1. 拆分 `AgentManager.vue`:按 tab lazy或合并 bootstrap 为单一 `page-init` 接口
2. 提取通用 `useListCache(key, fetcher, ttl)` 给 Bets/Users/Deposit 等
3. `LeagueMatchesPanel`:对已加载 `leagueId` 短 TTL 缓存
4. `api.ts``reconcileStaffSessionFromToken` 移到 token 变更时,而非每请求
### 阶段 C — 守卫与 bundle35 天)
1. `beforeEach` 改为同步 JWT/localStorage 校验;`/me` 仅登录/刷新时调用
2. Element Plus 改按需 + 去全量 CSS
3. 执行/合入 i18n `split-i18n.mjs`,首屏只加载当前语言
4. 瘦身 `App.vue` 全局样式
### 阶段 D — 度量与验收
```bash
pnpm --filter @thebet365/admin build:analyze
```
验收指标Chrome DevToolsFast 3G
- 切回已访问列表页:无重复全量列表 API或仅 background refresh
- `/users` 二次进入API 数从 3+ 降到 01
- 侧边栏切换guard 阶段无阻塞性 `/me`60s 内)
---
## 六、当前不必优先动的部分
- **ECharts**:已 async chunk仅 dashboard/settlement 加载
- **ContentRichEditor**:未全局引入,仅 contents 路由
- **deposit 30s 轮询**:后台流量,通常不是切页主因
- **ManageLayout computed 菜单**:开销相对小
---
## 七、推荐落地顺序
1. **只做一处**KeepAlive + 列表页 activated 缓存(阶段 A
2. **`/users` 最慢**:在 A 之后做 AgentManager API 合并/延迟加载(阶段 B
3. **首屏整体慢**:再动 Element Plus / i18n 拆分(阶段 C
---
## 八、关键文件索引
| 职责 | 路径 |
|------|------|
| 路由 + beforeEach | `apps/admin/src/router/index.ts` |
| 布局壳 | `apps/admin/src/layouts/ManageLayout.vue` |
| Session 水合 / TTL | `apps/admin/src/utils/session-hydrate.ts` |
| Auth store | `apps/admin/src/stores/auth.ts` |
| Axios 拦截器 | `apps/admin/src/api.ts` |
| Dashboard 入口 | `apps/admin/src/views/HomeEntry.vue` |
| Dashboard 数据缓存 | `apps/admin/src/composables/useAdminDashboard.ts` |
| Deposit 轮询 | `apps/admin/src/composables/useDepositPendingCount.ts` |
| Bootstrap | `apps/admin/src/main.ts` |
| Vite 分包 | `apps/admin/vite.config.ts` |
| 重型用户页 | `apps/admin/src/views/AgentManager.vue` |
| 充值 tab | `apps/admin/src/views/DepositManage.vue` |
| 路由 chunk 预取 | `apps/admin/src/utils/route-prefetch.ts` |
| 列表 stale 生命周期 | `apps/admin/src/composables/useStaleList.ts` |
---
## 九、度量验收DevTools
### Performance — 点击 → 路由切换
1. 打开 Chrome DevTools → **Performance**,勾选 Screenshots
2. 录制hover 侧边栏「注单管理」约 0.5s → 点击 → 停止
3. 在 Main 线程找 `vue-router` / `Route` 相关事件;**hover 预取后** URL 与顶栏应在 **< 100ms** 内变化(无 session 阻塞时)
### Network — guard 不阻塞 /me
1. DevTools → **Network**,过滤 `auth/me`
2. 登录后 60s 内连续切换 3 个菜单:**不应**每次切页都出现 `/manage/auth/me`
3. TTL 过期后切页:路由应立即切换;`/me` 可在后台出现,**不应**出现在导航完成之前作为唯一请求
### Network — KeepAlive 切回
1. 进入 `/bets` 等待列表加载 → 切到 `/matches` → 再切回 `/bets`
2. 第二次进入:**不应**重复全量 `GET /admin/bets`(或仅 silent refresh表格不白屏
3. `/users` 二次进入mount 时不应并行 3+ bootstrap API仅当前 tab + 可选 silent refresh
### 构建分析
```bash
pnpm --filter @thebet365/admin build:analyze
```
确认 `admin-users``admin-bets``admin-matches` 等独立 chunk 存在,主包不含完整列表页 SFC。

View File

@@ -1,695 +0,0 @@
# 创蓝短信ChuanglanTypeScript 全栈接入指南
> 适用场景:**全新独立项目**TypeScript 全栈Next.js / Remix / Nuxt 等),直连创蓝 API。
> 服务商:创蓝 253 云通讯(国际短信网关)
> API Endpoint`https://sgap.253.com/send/sms`
> 签名算法参考:`babylive-backend` 中 `ChuanglanClient.java` + `SignUtil.java`(已验证可用)
---
## 1. 整体架构
创蓝 `account` / `password` 是服务端密钥,**只能在服务端调用**,浏览器/客户端绝不直接接触创蓝。
```
┌─────────────┐ POST /api/sms/send ┌──────────────────┐ POST + sign ┌─────────────┐
│ 前端页面 │ ──────────────────────────▶│ TS 服务端 │ ──────────────────▶│ 创蓝 API │
│ (React 等) │◀────────────────────────── │ API Route / tRPC │◀────────────────── │ 253.com │
└─────────────┘ { sessionId } └──────────────────┘ messageId └─────────────┘
Redis / KV 缓存
(验证码 + 频控)
```
职责划分:
| 层 | 职责 |
|----|------|
| 前端 | 收集手机号、触发发送、倒计时 60s、提交验证码 + `sessionId` |
| 服务端 API | 频控、生成验证码、调创蓝、存/验缓存 |
| `lib/chuanglan` | 签名 + HTTP 请求,不含业务逻辑 |
| Redis | 验证码存储5 分钟 TTL、手机号/IP 频控60 秒 TTL |
---
## 2. 环境变量
```bash
# .env.local勿提交 Git
CHUANGLAN_ACCOUNT=your_account
CHUANGLAN_PASSWORD=your_password
CHUANGLAN_ENDPOINT=https://sgap.253.com/send/sms
CHUANGLAN_CONNECT_TIMEOUT_MS=10000
CHUANGLAN_READ_TIMEOUT_MS=10000
# 验证码业务
SMS_CODE_TTL_SECONDS=300 # 5 分钟
SMS_RATE_LIMIT_SECONDS=60 # 发送冷却
REDIS_URL=redis://127.0.0.1:6379
```
创蓝账号信息可向运维索取(与 babylive-backend `application.yml``chuanglan.*` 同源)。
---
## 3. 推荐目录结构
以 Next.js App Router 为例,其他 TS 全栈框架可平移 `lib/``types/`
```
src/
├── lib/
│ ├── chuanglan/
│ │ ├── client.ts # 创蓝 HTTP Client
│ │ ├── sign.ts # MD5 签名
│ │ └── config.ts # 读取环境变量
│ └── sms/
│ ├── templates.ts # 多语言短信模板
│ ├── code.ts # 验证码生成
│ └── service.ts # 发送 / 校验业务
├── app/
│ └── api/
│ └── sms/
│ ├── send/route.ts
│ └── verify/route.ts
├── types/
│ └── sms.ts
└── hooks/
└── use-sms-code.ts # 前端发送 + 倒计时
```
---
## 4. 创蓝 API 协议
### 4.1 请求
**Method** `POST`
**URL** `https://sgap.253.com/send/sms`
**Headers**
| Header | 说明 |
|--------|------|
| `Content-Type` | `application/json` |
| `nonce` | 毫秒时间戳字符串,如 `1718000000123` |
| `sign` | MD5 签名,见 4.2 |
**Body**
```json
{
"account": "your_account",
"mobile": "8613800138000",
"msg": "您的验证码是123456。5分钟内有效。",
"uid": "optional-session-id"
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| account | 是 | 创蓝账号 |
| mobile | 是 | 目标手机号,建议带国家码 |
| msg | 是 | 短信正文 |
| uid | 否 | 自定义 ID建议传本次验证码会话 ID |
> `nonce` 参与签名,放 Header**不进 Body**。
### 4.2 签名算法
1. 取 Body 全部字段 + `nonce`,组成键值对
2. 按 key **字典序升序**(等价 Java `TreeMap`
3. 依次拼接 `key + value`**跳过空值**`null` / `""` / 纯空白)
4. 末尾追加 `password`
5. 整体做 **MD5**,输出 **32 位小写** hex
```
sign = md5("account" + account + "mobile" + mobile + "msg" + msg + "nonce" + nonce + password)
```
### 4.3 响应
成功(`code === "0"`
```json
{
"code": "0",
"message": "提交成功",
"data": { "messageId": "162575412960104448" }
}
```
失败时 `code` 为非 `"0"` 字符串,`message` 为错误描述。
---
## 5. TypeScript 类型
```typescript
// src/types/sms.ts
export type SmsLang = 'zh' | 'en' | 'vi' | 'ms' | 'kh';
export interface ChuanglanSendBody {
account: string;
mobile: string;
msg: string;
uid?: string;
}
export interface ChuanglanSendResponse {
code: string;
message: string;
data?: { messageId: string };
}
export interface SmsSendResult {
success: boolean;
code: string;
message: string;
messageId?: string;
}
export interface SendSmsCodeRequest {
phone: string;
lang?: SmsLang;
}
export interface SendSmsCodeResponse {
sessionId: string;
}
export interface VerifySmsCodeRequest {
phone: string;
code: string;
sessionId: string;
}
export interface VerifySmsCodeResponse {
ok: true;
}
```
---
## 6. 服务端实现
### 6.1 配置
```typescript
// src/lib/chuanglan/config.ts
function required(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing env: ${name}`);
return v;
}
export const chuanglanConfig = {
account: required('CHUANGLAN_ACCOUNT'),
password: required('CHUANGLAN_PASSWORD'),
endpoint: process.env.CHUANGLAN_ENDPOINT ?? 'https://sgap.253.com/send/sms',
connectTimeoutMs: Number(process.env.CHUANGLAN_CONNECT_TIMEOUT_MS ?? 10_000),
readTimeoutMs: Number(process.env.CHUANGLAN_READ_TIMEOUT_MS ?? 10_000),
} as const;
export const smsConfig = {
codeTtlSeconds: Number(process.env.SMS_CODE_TTL_SECONDS ?? 300),
rateLimitSeconds: Number(process.env.SMS_RATE_LIMIT_SECONDS ?? 60),
} as const;
```
### 6.2 签名
```typescript
// src/lib/chuanglan/sign.ts
import crypto from 'node:crypto';
export function generateChuanglanSign(
password: string,
params: Record<string, string | undefined>,
): string {
const raw = Object.keys(params)
.sort()
.reduce((acc, key) => {
const value = params[key];
if (value != null && value.trim() !== '') {
return acc + key + value;
}
return acc;
}, '');
return crypto.createHash('md5').update(raw + password, 'utf8').digest('hex').toLowerCase();
}
```
### 6.3 创蓝 Client
```typescript
// src/lib/chuanglan/client.ts
import { chuanglanConfig } from './config';
import { generateChuanglanSign } from './sign';
import type { ChuanglanSendResponse, SmsSendResult } from '@/types/sms';
export async function sendChuanglanSms(
mobile: string,
msg: string,
uid?: string,
): Promise<SmsSendResult> {
const nonce = String(Date.now());
const body: Record<string, string> = {
account: chuanglanConfig.account,
mobile,
msg,
};
if (uid) body.uid = uid;
const sign = generateChuanglanSign(chuanglanConfig.password, { ...body, nonce });
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), chuanglanConfig.readTimeoutMs);
try {
const res = await fetch(chuanglanConfig.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
nonce,
sign,
},
body: JSON.stringify(body),
signal: controller.signal,
});
const data = (await res.json()) as ChuanglanSendResponse;
if (data.code === '0') {
return {
success: true,
code: data.code,
message: 'OK',
messageId: data.data?.messageId,
};
}
return { success: false, code: data.code, message: data.message };
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return { success: false, code: 'HTTP_ERROR', message };
} finally {
clearTimeout(timer);
}
}
```
### 6.4 短信模板
与 babylive-backend `sms.verify` 配置一致:
```typescript
// src/lib/sms/templates.ts
import type { SmsLang } from '@/types/sms';
const TEMPLATES: Record<string, string> = {
default: '您的验证码是:{code}。5分钟内有效。',
zh: '您的验证码是:{code}。5分钟内有效。',
en: 'Your verification code is {code}. Valid for 5 minutes.',
vi: 'Mã xác minh của bạn là {code}. Có hiệu lực trong 5 phút.',
ms: 'Kod pengesahan anda ialah {code}. Sah selama 5 minit.',
kh: 'កូដផ្ទៀងផ្ទាត់របស់អ្នកគឺ {code} ។ មានសុពលភាពរយៈពេល ៥ នាទី។',
};
export function renderVerifySms(lang: SmsLang | undefined, code: string): string {
const key = lang?.trim() || 'zh';
const tpl = TEMPLATES[key] ?? TEMPLATES.default ?? TEMPLATES.zh;
return tpl.replace('{code}', code);
}
```
```typescript
// src/lib/sms/code.ts
export function generateSixDigitCode(): string {
return String(Math.floor(Math.random() * 1_000_000)).padStart(6, '0');
}
```
### 6.5 业务 ServiceRedis
```typescript
// src/lib/sms/service.ts
import { randomUUID } from 'node:crypto';
import { sendChuanglanSms } from '@/lib/chuanglan/client';
import { smsConfig } from '@/lib/chuanglan/config';
import { generateSixDigitCode } from './code';
import { renderVerifySms } from './templates';
import type { SmsLang } from '@/types/sms';
// 按项目替换为 ioredis / @upstash/redis 等
import { redis } from '@/lib/redis';
const codeKey = (sessionId: string) => `sms:code:${sessionId}`;
const phoneRateKey = (phone: string) => `sms:rate:phone:${phone}`;
const ipRateKey = (ip: string) => `sms:rate:ip:${ip}`;
export class SmsRateLimitError extends Error {
constructor() {
super('发送太频繁请60秒后再试');
this.name = 'SmsRateLimitError';
}
}
export class SmsSendError extends Error {
code: string;
constructor(code: string, message: string) {
super(message);
this.name = 'SmsSendError';
this.code = code;
}
}
export async function sendVerifyCode(params: {
phone: string;
lang?: SmsLang;
clientIp: string;
}): Promise<{ sessionId: string }> {
const { phone, lang, clientIp } = params;
const [phoneLimited, ipLimited] = await Promise.all([
redis.exists(phoneRateKey(phone)),
redis.exists(ipRateKey(clientIp)),
]);
if (phoneLimited || ipLimited) throw new SmsRateLimitError();
const code = generateSixDigitCode();
const sessionId = randomUUID();
const msg = renderVerifySms(lang, code);
const result = await sendChuanglanSms(phone, msg, sessionId);
if (!result.success) {
throw new SmsSendError(result.code, result.message);
}
await Promise.all([
redis.set(codeKey(sessionId), JSON.stringify({ phone, code }), 'EX', smsConfig.codeTtlSeconds),
redis.set(phoneRateKey(phone), '1', 'EX', smsConfig.rateLimitSeconds),
redis.set(ipRateKey(clientIp), '1', 'EX', smsConfig.rateLimitSeconds),
]);
return { sessionId };
}
export async function verifyCode(params: {
phone: string;
code: string;
sessionId: string;
}): Promise<void> {
const raw = await redis.get(codeKey(params.sessionId));
if (!raw) throw new Error('验证码已过期');
const cached = JSON.parse(raw) as { phone: string; code: string };
if (cached.phone !== params.phone || cached.code !== params.code) {
throw new Error('验证码错误');
}
await redis.del(codeKey(params.sessionId));
}
```
---
## 7. API RouteNext.js 示例)
### 7.1 发送验证码
```typescript
// src/app/api/sms/send/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendVerifyCode, SmsRateLimitError, SmsSendError } from '@/lib/sms/service';
import type { SendSmsCodeRequest } from '@/types/sms';
function getClientIp(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| req.headers.get('x-real-ip')
|| '0.0.0.0'
);
}
export async function POST(req: NextRequest) {
const body = (await req.json()) as SendSmsCodeRequest;
if (!body.phone?.trim()) {
return NextResponse.json({ message: 'phone 必填' }, { status: 400 });
}
try {
const { sessionId } = await sendVerifyCode({
phone: body.phone.trim(),
lang: body.lang,
clientIp: getClientIp(req),
});
return NextResponse.json({ sessionId });
} catch (err) {
if (err instanceof SmsRateLimitError) {
return NextResponse.json({ message: err.message }, { status: 429 });
}
if (err instanceof SmsSendError) {
return NextResponse.json({ message: err.message, code: err.code }, { status: 502 });
}
return NextResponse.json({ message: '服务器错误' }, { status: 500 });
}
}
```
### 7.2 校验验证码
```typescript
// src/app/api/sms/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyCode } from '@/lib/sms/service';
import type { VerifySmsCodeRequest } from '@/types/sms';
export async function POST(req: NextRequest) {
const body = (await req.json()) as VerifySmsCodeRequest;
if (!body.phone || !body.code || !body.sessionId) {
return NextResponse.json({ message: '参数不完整' }, { status: 400 });
}
try {
await verifyCode(body);
return NextResponse.json({ ok: true });
} catch (err) {
const message = err instanceof Error ? err.message : '校验失败';
return NextResponse.json({ message }, { status: 400 });
}
}
```
### 7.3 对外 API 契约
**发送**
```
POST /api/sms/send
Content-Type: application/json
{ "phone": "8613800138000", "lang": "zh" }
→ 200 { "sessionId": "uuid" }
→ 429 { "message": "发送太频繁请60秒后再试" }
→ 502 { "message": "...", "code": "创蓝错误码" }
```
**校验**
```
POST /api/sms/verify
Content-Type: application/json
{ "phone": "8613800138000", "code": "123456", "sessionId": "uuid" }
→ 200 { "ok": true }
→ 400 { "message": "验证码错误或已过期" }
```
---
## 8. 前端调用
### 8.1 API Client
```typescript
// src/lib/api/sms.ts
import type { SendSmsCodeResponse, SmsLang, VerifySmsCodeResponse } from '@/types/sms';
export async function sendSmsCode(phone: string, lang: SmsLang = 'zh'): Promise<string> {
const res = await fetch('/api/sms/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, lang }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.message ?? '发送失败');
return (json as SendSmsCodeResponse).sessionId;
}
export async function verifySmsCode(
phone: string,
code: string,
sessionId: string,
): Promise<void> {
const res = await fetch('/api/sms/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, code, sessionId }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.message ?? '校验失败');
void json as VerifySmsCodeResponse;
}
```
### 8.2 React Hook 示例
```typescript
// src/hooks/use-sms-code.ts
'use client';
import { useCallback, useRef, useState } from 'react';
import { sendSmsCode } from '@/lib/api/sms';
import type { SmsLang } from '@/types/sms';
const COOLDOWN_SECONDS = 60;
export function useSmsCode(lang: SmsLang = 'zh') {
const [sessionId, setSessionId] = useState<string | null>(null);
const [countdown, setCountdown] = useState(0);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startCountdown = useCallback(() => {
setCountdown(COOLDOWN_SECONDS);
timerRef.current = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
if (timerRef.current) clearInterval(timerRef.current);
return 0;
}
return prev - 1;
});
}, 1000);
}, []);
const send = useCallback(async (phone: string) => {
if (countdown > 0 || sending) return;
setSending(true);
setError(null);
try {
const id = await sendSmsCode(phone, lang);
setSessionId(id);
startCountdown();
} catch (err) {
setError(err instanceof Error ? err.message : '发送失败');
} finally {
setSending(false);
}
}, [countdown, sending, lang, startCountdown]);
return { sessionId, countdown, sending, error, send };
}
```
页面中使用:
```tsx
const { sessionId, countdown, sending, error, send } = useSmsCode('zh');
<button disabled={sending || countdown > 0} onClick={() => send(phone)}>
{countdown > 0 ? `${countdown}s 后重试` : '获取验证码'}
</button>
// 提交表单时带上 sessionId + code 调 /api/sms/verify 或合并进登录/注册接口
```
---
## 9. 手机号格式
- 国际短信建议带国家码:`8613800138000``86` + 11 位)
- 前端可在提交前统一格式化,或在 `service.ts` 中做 normalize
- 创蓝账号为国际网关(`sgap.253.com`),非中国大陆号段需确认创蓝侧已开通对应路由
---
## 10. 业务规则(与 babylive 对齐)
| 规则 | 值 |
|------|-----|
| 验证码位数 | 6 位数字 |
| 验证码有效期 | 5 分钟 |
| 同手机号冷却 | 60 秒 |
| 同 IP 冷却 | 60 秒 |
| 校验成功后 | 立即删除缓存(一次性) |
---
## 11. 签名自测
接入后用固定参数验证签名是否与 Java 端一致:
```typescript
import { generateChuanglanSign } from '@/lib/chuanglan/sign';
const sign = generateChuanglanSign('your_password', {
account: 'your_account',
mobile: '8613800138000',
msg: '您的验证码是123456。5分钟内有效。',
uid: 'test-session-001',
nonce: '1718000000123',
});
console.log(sign);
// 应与 Java SignUtil.generateSign 输出完全相同
```
检查清单:
- [ ] key 字典序排序
- [ ]`uid` 不参与签名
- [ ] `nonce` 在 Header + 签名参数,不在 Body
- [ ] MD5 32 位小写
- [ ] UTF-8 编码
---
## 12. 安全与运维
1. `CHUANGLAN_*` 仅服务端环境变量,不进 `NEXT_PUBLIC_*`
2. 日志中手机号脱敏、禁止打印验证码明文
3. 生产环境 Redis 必开;无 Redis 时不可用内存 MapServerless 多实例会失效)
4. `uid` / `sessionId` 建议用 UUID便于与创蓝 `messageId` 对账
5. 监控创蓝 `code` 分布与 `HTTP_ERROR` 比例
---
## 13. 接入步骤速查
```
1. 配置 .env.local创蓝账号 + Redis
2. 复制 lib/chuanglan/*sign + client
3. 复制 lib/sms/*templates + service
4. 添加 /api/sms/send 与 /api/sms/verify
5. 前端 useSmsCode + 表单提交携带 sessionId
6. 跑签名自测,发一条真实短信验证
```
新项目按此文档从零接入即可,**无需依赖 babylive-backend 运行时**;签名算法以该仓库 `ChuanglanClient.java` 为准。

View File

@@ -139,6 +139,8 @@ docs\docker\build-and-export-images.bat --export-only
## 五、上传到服务器并部署
完整分步说明(本地打包 → 上传 → 终端执行 → 验证)见上级文档 **[Docker部署指南.md 第八节](../Docker部署指南.md#八推荐发版流程本地打包--上传--线上更新)**。
### 1. 上传
将以下内容传到服务器同一目录(如 `/www/wwwroot/thebet365`

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

@@ -2,8 +2,7 @@
本文档说明 thebet365 **玩家端短信验证码**(注册 / 找回密码)在后端的日志行为,以及如何与创蓝控制台对账、排查「收不到码」问题。
相关代码:`apps/api/src/domains/identity/sms/`
创蓝接入总览见 [chuanglan-sms-js-guide.md](./chuanglan-sms-js-guide.md)。
相关代码:`apps/api/src/domains/identity/sms/``SmsService``ChuanglanClient`
---

View File

@@ -1,3 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 32 32">
<image xlink:href="/logo.png" width="32" height="32" preserveAspectRatio="xMidYMid meet" />
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#003D6B"/>
<path d="M7.5 6.5h6.2c2.8 0 4.8 1.4 4.8 3.6 0 1.6-1 2.7-2.4 3.2v.1c1.8.4 3.1 1.8 3.1 3.7 0 2.5-2.2 4-5.2 4H7.5V6.5zm4.5 5.9h1.6c1.1 0 1.7-.6 1.7-1.5s-.6-1.5-1.7-1.5H12v3zm0 6h1.9c1.2 0 1.9-.6 1.9-1.6s-.7-1.6-1.9-1.6H12v3.2z" fill="#FFF"/>
<circle cx="24.5" cy="24.5" r="5" fill="#F8971F"/>
<g fill="#FFF">
<path d="M21.3 22.8h1.4l-.1.7h-1.2v.8h1v.7h-1v.8h1.2l.1.7h-1.4z" />
<path d="M23.2 22.8h1.9v.7h-1.3v.6h1.1v.7h-1.1v.6h1.3v.7h-1.9z" />
<path d="M25.5 22.8h1.5l-.1.7h-1.3v.5h1.1c.3 0 .5.2.5.5v1.2c0 .3-.2.5-.5.5h-1.4l.1-.7h1.1v-1h-1.1c-.3 0-.5-.2-.5-.5v-.7c0-.3.2-.5.5-.5z" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 205 B

After

Width:  |  Height:  |  Size: 729 B

View File

@@ -42,6 +42,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'User not found',
'ms-MY': 'Pengguna tidak dijumpai',
},
CANNOT_DELETE_SELF: {
'zh-CN': '不能删除自己',
'en-US': 'Cannot delete yourself',
'ms-MY': 'Tidak boleh memadam diri sendiri',
},
STAFF_NOT_FOUND: {
'zh-CN': '管理员不存在',
'en-US': 'Staff member not found',
'ms-MY': 'Ahli kakitangan tidak dijumpai',
},
CANNOT_DELETE_LAST_SUPER_ADMIN: {
'zh-CN': '不能删除唯一的超级管理员',
'en-US': 'Cannot delete the last super admin',
'ms-MY': 'Tidak boleh memadam pentadbir super terakhir',
},
PASSWORD_CHANGE_DISABLED: {
'zh-CN': '当前平台未开放玩家自行修改密码',
'en-US': 'Password change is disabled for players',
@@ -842,6 +857,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Content not found',
'ms-MY': 'Kandungan tidak dijumpai',
},
MESSAGE_NOT_FOUND: {
'zh-CN': '消息不存在',
'en-US': 'Message not found',
'ms-MY': 'Mesej tidak dijumpai',
},
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',

View File

@@ -44,6 +44,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'User not found',
'ms-MY': 'Pengguna tidak dijumpai',
},
CANNOT_DELETE_SELF: {
'zh-CN': '不能删除自己',
'en-US': 'Cannot delete yourself',
'ms-MY': 'Tidak boleh memadam diri sendiri',
},
STAFF_NOT_FOUND: {
'zh-CN': '管理员不存在',
'en-US': 'Staff member not found',
'ms-MY': 'Ahli kakitangan tidak dijumpai',
},
CANNOT_DELETE_LAST_SUPER_ADMIN: {
'zh-CN': '不能删除唯一的超级管理员',
'en-US': 'Cannot delete the last super admin',
'ms-MY': 'Tidak boleh memadam pentadbir super terakhir',
},
PASSWORD_CHANGE_DISABLED: {
'zh-CN': '当前平台未开放玩家自行修改密码',
'en-US': 'Password change is disabled for players',
@@ -844,6 +859,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Content not found',
'ms-MY': 'Kandungan tidak dijumpai',
},
MESSAGE_NOT_FOUND: {
'zh-CN': '消息不存在',
'en-US': 'Message not found',
'ms-MY': 'Mesej tidak dijumpai',
},
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',

202
pnpm-lock.yaml generated
View File

@@ -44,6 +44,12 @@ importers:
typescript:
specifier: ^5.7.3
version: 5.7.3
unplugin-auto-import:
specifier: ^21.0.0
version: 21.0.0(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.7.3)))
unplugin-vue-components:
specifier: ^32.1.0
version: 32.1.0(vue@3.5.35(typescript@5.7.3))
vite:
specifier: ^6.0.11
version: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(terser@5.48.0)
@@ -1832,6 +1838,10 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chrome-trace-event@1.0.4:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'}
@@ -1922,6 +1932,9 @@ packages:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
@@ -2164,6 +2177,10 @@ packages:
resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
engines: {node: '>=8'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-scope@5.1.1:
resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==}
engines: {node: '>=8.0.0'}
@@ -2188,6 +2205,9 @@ packages:
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
@@ -2681,6 +2701,9 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@3.13.1:
resolution: {integrity: sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==}
hasBin: true
@@ -2753,6 +2776,10 @@ packages:
resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==}
engines: {node: '>=6.11.5'}
local-pkg@1.2.1:
resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==}
engines: {node: '>=14'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@@ -2893,6 +2920,9 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2962,6 +2992,10 @@ packages:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
obug@2.1.3:
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
engines: {node: '>=12.20.0'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@@ -3097,6 +3131,9 @@ packages:
resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
engines: {node: '>=8'}
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
@@ -3156,6 +3193,9 @@ packages:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -3178,6 +3218,10 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
redis-errors@1.2.0:
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
engines: {node: '>=4'}
@@ -3268,6 +3312,9 @@ packages:
resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==}
engines: {node: '>= 10.13.0'}
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
semver@6.3.0:
resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
hasBin: true
@@ -3421,6 +3468,9 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
@@ -3619,6 +3669,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
uglify-js@3.19.3:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
engines: {node: '>=0.8.0'}
@@ -3635,6 +3688,10 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
unimport@5.7.0:
resolution: {integrity: sha512-njnL6sp8lEA8QQbZrt+52p/g4X0rw3bnGGmUcJnt1jeG8+iiqO779aGz0PirCtydAIVcuTBRlJ52F0u46z309Q==}
engines: {node: '>=18.12.0'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
@@ -3643,6 +3700,40 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
unplugin-auto-import@21.0.0:
resolution: {integrity: sha512-vWuC8SwqJmxZFYwPojhOhOXDb5xFhNNcEVb9K/RFkyk/3VnfaOjzitWN7v+8DEKpMjSsY2AEGXNgt6I0yQrhRQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@nuxt/kit': ^4.0.0
'@vueuse/core': '*'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@vueuse/core':
optional: true
unplugin-utils@0.3.1:
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
engines: {node: '>=20.19.0'}
unplugin-vue-components@32.1.0:
resolution: {integrity: sha512-YiUkSxuRjab18XFOrX5VsIxXzccrfmHVGsGeJgSgklb829DQmCy9E4vvDUE4tuvZZdxyFJZX0Oc4TPnnxiiMyg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@nuxt/kit': ^3.2.2 || ^4.0.0
vue: ^3.0.0
peerDependenciesMeta:
'@nuxt/kit':
optional: true
unplugin@2.3.11:
resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==}
engines: {node: '>=18.12.0'}
unplugin@3.0.0:
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
engines: {node: ^20.19.0 || >=22.12.0}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -3787,6 +3878,9 @@ packages:
resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==}
engines: {node: '>=10.13.0'}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
webpack@5.106.0:
resolution: {integrity: sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==}
engines: {node: '>=10.13.0'}
@@ -5627,6 +5721,10 @@ snapshots:
dependencies:
readdirp: 4.1.2
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chrome-trace-event@1.0.4: {}
ci-info@3.2.0: {}
@@ -5709,6 +5807,8 @@ snapshots:
readable-stream: 3.6.2
typedarray: 0.0.6
confbox@0.1.8: {}
confbox@0.2.4: {}
consola@3.4.2: {}
@@ -5939,6 +6039,8 @@ snapshots:
escape-string-regexp@2.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-scope@5.1.1:
dependencies:
esrecurse: 4.3.0
@@ -5956,6 +6058,10 @@ snapshots:
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
etag@1.8.1: {}
events@3.3.0: {}
@@ -6700,6 +6806,8 @@ snapshots:
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@3.13.1:
dependencies:
argparse: 1.0.10
@@ -6769,6 +6877,12 @@ snapshots:
loader-runner@4.3.2: {}
local-pkg@1.2.1:
dependencies:
mlly: 1.8.2
pkg-types: 2.3.1
quansync: 0.2.11
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@@ -6881,6 +6995,13 @@ snapshots:
minipass@7.1.3: {}
mlly@1.8.2:
dependencies:
acorn: 8.16.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.4
ms@2.1.3: {}
muggle-string@0.4.1: {}
@@ -6932,6 +7053,8 @@ snapshots:
object-inspect@1.13.4: {}
obug@2.1.3: {}
ohash@2.0.11: {}
on-finished@2.4.1:
@@ -7060,6 +7183,12 @@ snapshots:
dependencies:
find-up: 4.1.0
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.2
pathe: 2.0.3
pkg-types@2.3.1:
dependencies:
confbox: 0.2.4
@@ -7119,6 +7248,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
quansync@0.2.11: {}
range-parser@1.2.1: {}
raw-body@3.0.2:
@@ -7143,6 +7274,8 @@ snapshots:
readdirp@4.1.2: {}
readdirp@5.0.0: {}
redis-errors@1.2.0: {}
redis-parser@3.0.0:
@@ -7254,6 +7387,8 @@ snapshots:
ajv-formats: 2.1.1(ajv@8.20.0)
ajv-keywords: 5.1.0(ajv@8.20.0)
scule@1.3.0: {}
semver@6.3.0: {}
semver@6.3.1: {}
@@ -7414,6 +7549,10 @@ snapshots:
strip-json-comments@3.1.1: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
strtok3@10.3.5:
dependencies:
'@tokenizer/token': 0.3.0
@@ -7562,6 +7701,8 @@ snapshots:
typescript@5.9.3: {}
ufo@1.6.4: {}
uglify-js@3.19.3:
optional: true
@@ -7573,10 +7714,69 @@ snapshots:
undici-types@6.21.0: {}
unimport@5.7.0:
dependencies:
acorn: 8.16.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
local-pkg: 1.2.1
magic-string: 0.30.21
mlly: 1.8.2
pathe: 2.0.3
picomatch: 4.0.4
pkg-types: 2.3.1
scule: 1.3.0
strip-literal: 3.1.0
tinyglobby: 0.2.17
unplugin: 2.3.11
unplugin-utils: 0.3.1
universalify@2.0.1: {}
unpipe@1.0.0: {}
unplugin-auto-import@21.0.0(@vueuse/core@14.3.0(vue@3.5.35(typescript@5.7.3))):
dependencies:
local-pkg: 1.2.1
magic-string: 0.30.21
picomatch: 4.0.4
unimport: 5.7.0
unplugin: 2.3.11
unplugin-utils: 0.3.1
optionalDependencies:
'@vueuse/core': 14.3.0(vue@3.5.35(typescript@5.7.3))
unplugin-utils@0.3.1:
dependencies:
pathe: 2.0.3
picomatch: 4.0.4
unplugin-vue-components@32.1.0(vue@3.5.35(typescript@5.7.3)):
dependencies:
chokidar: 5.0.0
local-pkg: 1.2.1
magic-string: 0.30.21
mlly: 1.8.2
obug: 2.1.3
picomatch: 4.0.4
tinyglobby: 0.2.17
unplugin: 3.0.0
unplugin-utils: 0.3.1
vue: 3.5.35(typescript@5.7.3)
unplugin@2.3.11:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.16.0
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
unplugin@3.0.0:
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@@ -7680,6 +7880,8 @@ snapshots:
webpack-sources@3.5.0: {}
webpack-virtual-modules@0.6.2: {}
webpack@5.106.0:
dependencies:
'@types/eslint-scope': 3.7.7

View File

@@ -156,9 +156,11 @@ validate_prod_env() {
[ -n "$postgres_password" ] || die ".env.docker 缺少 POSTGRES_PASSWORD"
[ -n "$jwt_secret" ] || die ".env.docker 缺少 JWT_SECRET"
if [ "$allow_defaults" != "true" ]; then
[ "$postgres_password" != "thebet365" ] || die "POSTGRES_PASSWORD 仍是示例值;如确为测试环境,请加 --allow-default-secrets"
[ "$jwt_secret" != "change-me-in-production-use-long-random-string" ] || die "JWT_SECRET 仍是示例值;如确为测试环境,请加 --allow-default-secrets"
if [ "$postgres_password" = "thebet365" ]; then
warn "POSTGRES_PASSWORD 仍是示例值 thebet365生产环境建议尽快修改"
fi
if [ "$jwt_secret" = "change-me-in-production-use-long-random-string" ]; then
warn "JWT_SECRET 仍是示例值,生产环境建议尽快修改"
fi
if [ "$seed_database" = "true" ]; then