Compare commits

...

31 Commits

Author SHA1 Message Date
9c5e8d6f5c fix(docker): multi-theme packaging with PS1 orchestration and full bundle export
Replace all-themes bat logic with PowerShell to survive branch checkout; add api-admin and full-themes service modes; fix bat findstr path check and cmd exit codes; document six-in-one tar and update gitignore for player/full-themes artifacts.
2026-06-24 14:18:31 +08:00
25dd0cf514 fix: normalize bat CRLF line endings; rewrite all-themes builder with git stash + goto pattern 2026-06-24 11:29:14 +08:00
5359c9baad fix: add missing formatMoneyCompact import in WalletStatsPanel; fix bat cwd drift in all-themes builder 2026-06-24 11:25:41 +08:00
0c2149bfc0 feat: add multi-theme docker compose configuration and batch packaging scripts 2026-06-24 10:57:31 +08:00
71203c66bb fix(player): 修复主题分支网站图标不显示
favicon.svg 原外链 /logo.png 浏览器无法加载;改为内嵌 base64

index.html 增加 logo.png 作为 favicon 回退;theme-3 theme-color 对齐 #0F2027
2026-06-23 16:31:38 +08:00
acc7391369 feat(catalog): 玩家端隐藏已结算赛事与冠军盘联赛
- listPublished/listUpcomingPublished 排除 SETTLED 及冠军盘已结算联赛

- outright.listForPlayer 仅返回 PUBLISHED

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

- 补充 matches/outright 单测
2026-06-23 15:02:20 +08:00
005cdf070f fix(admin): 恢复 Settlement adminSelectionLabel 导入并修正 walletRemarkLabel 类型 2026-06-23 14:01:32 +08:00
d3c211411b feat(admin+api+player): 优胜赛结算态、禁止新增单场与钱包流水展示优化
- API/管理端:优胜赛 SETTLED 后禁止新增单场,列表与子页展示结算状态
- 玩家端:已结算 outright 只读展示并高亮冠军
- 管理端:结算后 stale 标记驱动列表刷新;财务流水时间与备注 i18n 优化
- shared:txDisplayAmount 与 LEAGUE_OUTRIGHT_SETTLED 错误码
2026-06-23 12:20:40 +08:00
ce84226219 feat(admin+api): 代理停用默认、结算加固与冒烟配置探针
- 代理层级默认授信比例与停用冻结/禁登全局默认

- 结算预览去重、比分校验、串关当场判负与市场类型校验

- 站内信 Banner/公告自动通知开关;开发环境动态 API 端口

- 扩充 RBAC/结算/认证/返现单元测试与 agent skills
2026-06-23 11:08:41 +08:00
fa06fee64c fix(player+admin): 移动端 PWA 安全区、防缩放与局域网调试
玩家端适配主屏幕 safe-area、底部导航滚动隐藏与全屏布局;双端禁止页面缩放并开放 Vite host 供手机联调。
2026-06-22 17:58:51 +08:00
b9257cc50a fix(admin): 优化列表布局间距、滚动固定与侧栏子路由选中
统一嵌套卡片内边距与 Tabs 外壳样式,联赛单场支持表头/分页固定滚动;子页面下代理玩家菜单保持高亮。
2026-06-22 16:48:40 +08:00
78b426a388 fix(admin): 刷新 session 时保留员工菜单权限 visibleMenus 2026-06-22 16:19:19 +08:00
f20243fa8a refactor(player): 重构注单详情页为票据式布局并突出净赢 2026-06-22 15:27:58 +08:00
e0ce36cc48 docs: 新增四套主题 Docker 部署与邀请链接选主题任务文档
汇总多 player 容器部署、分支同步、发版流程及后台按主题生成邀请链接的阶段 F 设计,便于下次直接实施。
2026-06-22 14:57:56 +08:00
fa50782ec5 fix(admin): 补全赛事日志操作类型中英文翻译
赛事日志「操作」列此前因 pages 文案包缺少 audit.action 赛事相关键而显示英文常量,现已补全封盘、盘口、冠军盘等全部操作类型翻译。
2026-06-22 14:42:14 +08:00
1210142a33 feat(admin+api+player): 站内信手动发送、媒体库选择与发送记录详情
新增管理员手动发送站内信(三语富文本),支持发送记录查看与删除;修复全站「从媒体库选择」因分类过滤导致列表为空的问题;玩家端支持渲染管理员自定义消息;并优化后台列表页双层卡片布局。
2026-06-22 14:01:31 +08:00
648c314e23 feat(admin+api): 列表页子路由重构、结算历史与充值截图清理
赛事/代理管理从表格展开改为子路由详情页;结算页增加预览弹窗与历史记录;媒体库支持截图存储统计与自动/手动清理;Player 端充值截图压缩为 WebP 300KB。
2026-06-22 12:28:25 +08:00
499522f3fb feat(admin): 赛事管理单赛事信息增加筛选与排序功能 (支持按有下注过滤及按注单数/投注额排序) 2026-06-18 17:45:25 +08:00
7288ef3f57 style: separate header back-bar and tabs in InboxHubView on main 2026-06-18 17:37:46 +08:00
3f441477b8 docs: update AGENTS.md read count 2026-06-18 17:30:15 +08:00
c40ebb56ca fix(admin): 发版 chunk 自动刷新、nginx 缓存策略与审计页 i18n
lazy route 加载失败时刷新入口;index.html 不缓存;补全 audit.col.operator/ip 等拆包遗漏 key。
2026-06-18 16:59:39 +08:00
e39798ae0a perf(admin): 实现切页即时导航与列表 loading 壳
非阻塞 session 刷新、侧边栏 chunk 预取、KeepAlive 对齐,以及按 tab 延迟加载重型列表页。
2026-06-18 16:45:01 +08:00
a832998b0a feat(admin): 将新增赛事按钮移至联赛操作栏,并移除列表下方的旧按钮 2026-06-18 15:41:56 +08:00
5baac41ce8 perf(admin): 优化管理端页面切换性能与包体积
- 阶段 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:30:27 +08:00
4244c9e10b docs: 扩充 AGENTS 中文速查与管理端切页性能分析文档 2026-06-18 14:55:57 +08:00
b929fd01c0 docs: 完善镜像发版流程文档并清理过时文档
补充本地打包到线上更新的分步说明;部署脚本对示例密钥改为警告而非阻断。
2026-06-18 14:35:05 +08:00
4de320f143 fix(player): 冠军盘弹窗支持赔率变更轮询与确认下注
- 打开弹窗后定时拉取最新赔率并展示变更提示
- 赔率变动时按钮切换为「接受变更并下注」,提交前自动锁定新版本
- 接口返回 ODDS_CHANGED 时刷新赔率并引导用户重新确认
2026-06-18 13:46:44 +08:00
633ff7b559 docs: 添加 theme-2/3/4 功能同步任务说明
记录 main 相对各主题分支的缺口、可原样同步范围、Player 端合并策略与验证清单。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 18:00:25 +08:00
f9343b00af feat(player/admin/api): 站内邮箱、在线状态、员工菜单权限与内容管理增强
API:
- 新增 player-messages 域:充值审核通过/拒绝、Banner/公告推广消息,支持多语言模板
- 新增 presence 域:Redis 心跳在线状态,管理端可查询在线玩家数
- User 表增加 visible_menus 字段;新增 player_messages 表及迁移
- 充值审核通过/拒绝时按系统配置自动写入玩家站内消息
- 管理端新增 GET /deposit-orders/pending-count、GET /presence/online-count
- 玩家端新增消息 CRUD、presence/ping、home 返回 inbox 开关配置
- 员工管理支持 visibleMenus 配置与删除保护(不能删自己/最后超管)
- SystemConfig 增加 inbox 功能开关及各类通知开关

Admin:
- 员工管理:按角色默认菜单 + 可勾选可见菜单项
- ManageLayout:按 visibleMenus 过滤侧栏;充值待审数量角标轮询
- Contents:富文本编辑器、图片字段组件重构
- DashboardPlayers:展示在线玩家数;AdminPlayerStatusCell 在线状态列
- 多页面 i18n 与权限细节调整

Player:
- 站内邮箱中心(InboxHub):消息列表/详情、未读角标、一键已读/删除
- 公告列表与详情页;走马灯可跳转详情
- 客服 Modal 改为 Panel,与邮箱 Hub 整合
- 充值状态轮询通知;presence 心跳;BetSlip 清空二次确认
- HomeView 今日赛事板块;FootballView 等体验优化

Shared: 新增 CANNOT_DELETE_SELF、STAFF_NOT_FOUND、MESSAGE_NOT_FOUND 等错误码
Docs: 玩家端缺失功能分析文档
Chore: 移除 .agents/skills 设计类 skill 文件
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 17:51:10 +08:00
e9a23de935 fix(admin/player/api): 同步单关串关持久化、仅串关可选与盘口状态展示
1. 后台/API:赛事详情返回 allowSingle/allowParlay,管理端 mapMarkets 正确回读
2. 玩家端:仅关单关时仍可点选加入串关;投注抽屉禁用单关提交并提示
3. 玩家端:盘口暂停/已关闭状态标签;MarketTypeTile 展示 statusLabel

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-17 11:08:43 +08:00
f34fe54489 fix(player): ????????????????
?????????????????+??+?????????????????????????????????/???????????????

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-16 16:25:27 +08:00
203 changed files with 19105 additions and 4320 deletions

View File

@@ -0,0 +1,213 @@
---
name: receiving-code-review
description: Use when receiving code review feedback, before implementing suggestions, especially if feedback seems unclear or technically questionable - requires technical rigor and verification, not performative agreement or blind implementation
---
# Code Review Reception
## Overview
Code review requires technical evaluation, not emotional performance.
**Core principle:** Verify before implementing. Ask before assuming. Technical correctness over social comfort.
## The Response Pattern
```
WHEN receiving code review feedback:
1. READ: Complete feedback without reacting
2. UNDERSTAND: Restate requirement in own words (or ask)
3. VERIFY: Check against codebase reality
4. EVALUATE: Technically sound for THIS codebase?
5. RESPOND: Technical acknowledgment or reasoned pushback
6. IMPLEMENT: One item at a time, test each
```
## Forbidden Responses
**NEVER:**
- "You're absolutely right!" (explicit instruction-file violation)
- "Great point!" / "Excellent feedback!" (performative)
- "Let me implement that now" (before verification)
**INSTEAD:**
- Restate the technical requirement
- Ask clarifying questions
- Push back with technical reasoning if wrong
- Just start working (actions > words)
## Handling Unclear Feedback
```
IF any item is unclear:
STOP - do not implement anything yet
ASK for clarification on unclear items
WHY: Items may be related. Partial understanding = wrong implementation.
```
**Example:**
```
your human partner: "Fix 1-6"
You understand 1,2,3,6. Unclear on 4,5.
❌ WRONG: Implement 1,2,3,6 now, ask about 4,5 later
✅ RIGHT: "I understand items 1,2,3,6. Need clarification on 4 and 5 before proceeding."
```
## Source-Specific Handling
### From your human partner
- **Trusted** - implement after understanding
- **Still ask** if scope unclear
- **No performative agreement**
- **Skip to action** or technical acknowledgment
### From External Reviewers
```
BEFORE implementing:
1. Check: Technically correct for THIS codebase?
2. Check: Breaks existing functionality?
3. Check: Reason for current implementation?
4. Check: Works on all platforms/versions?
5. Check: Does reviewer understand full context?
IF suggestion seems wrong:
Push back with technical reasoning
IF can't easily verify:
Say so: "I can't verify this without [X]. Should I [investigate/ask/proceed]?"
IF conflicts with your human partner's prior decisions:
Stop and discuss with your human partner first
```
**your human partner's rule:** "External feedback - be skeptical, but check carefully"
## YAGNI Check for "Professional" Features
```
IF reviewer suggests "implementing properly":
grep codebase for actual usage
IF unused: "This endpoint isn't called. Remove it (YAGNI)?"
IF used: Then implement properly
```
**your human partner's rule:** "You and reviewer both report to me. If we don't need this feature, don't add it."
## Implementation Order
```
FOR multi-item feedback:
1. Clarify anything unclear FIRST
2. Then implement in this order:
- Blocking issues (breaks, security)
- Simple fixes (typos, imports)
- Complex fixes (refactoring, logic)
3. Test each fix individually
4. Verify no regressions
```
## When To Push Back
Push back when:
- Suggestion breaks existing functionality
- Reviewer lacks full context
- Violates YAGNI (unused feature)
- Technically incorrect for this stack
- Legacy/compatibility reasons exist
- Conflicts with your human partner's architectural decisions
**How to push back:**
- Use technical reasoning, not defensiveness
- Ask specific questions
- Reference working tests/code
- Involve your human partner if architectural
**If you're uncomfortable pushing back out loud:** Name that tension, then tell your partner about the issue you've seen. They'll appreciate your honesty.
## Acknowledging Correct Feedback
When feedback IS correct:
```
✅ "Fixed. [Brief description of what changed]"
✅ "Good catch - [specific issue]. Fixed in [location]."
✅ [Just fix it and show in the code]
❌ "You're absolutely right!"
❌ "Great point!"
❌ "Thanks for catching that!"
❌ "Thanks for [anything]"
❌ ANY gratitude expression
```
**Why no thanks:** Actions speak. Just fix it. The code itself shows you heard the feedback.
**If you catch yourself about to write "Thanks":** DELETE IT. State the fix instead.
## Gracefully Correcting Your Pushback
If you pushed back and were wrong:
```
✅ "You were right - I checked [X] and it does [Y]. Implementing now."
✅ "Verified this and you're correct. My initial understanding was wrong because [reason]. Fixing."
❌ Long apology
❌ Defending why you pushed back
❌ Over-explaining
```
State the correction factually and move on.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Performative agreement | State requirement or just act |
| Blind implementation | Verify against codebase first |
| Batch without testing | One at a time, test each |
| Assuming reviewer is right | Check if breaks things |
| Avoiding pushback | Technical correctness > comfort |
| Partial implementation | Clarify all items first |
| Can't verify, proceed anyway | State limitation, ask for direction |
## Real Examples
**Performative Agreement (Bad):**
```
Reviewer: "Remove legacy code"
❌ "You're absolutely right! Let me remove that..."
```
**Technical Verification (Good):**
```
Reviewer: "Remove legacy code"
✅ "Checking... build target is 10.15+, this API needs 13+. Need legacy for backward compat. Current impl has wrong bundle ID - fix it or drop pre-13 support?"
```
**YAGNI (Good):**
```
Reviewer: "Implement proper metrics tracking with database, date filters, CSV export"
✅ "Grepped codebase - nothing calls this endpoint. Remove it (YAGNI)? Or is there usage I'm missing?"
```
**Unclear Item (Good):**
```
your human partner: "Fix items 1-6"
You understand 1,2,3,6. Unclear on 4,5.
✅ "Understand 1,2,3,6. Need clarification on 4 and 5 before implementing."
```
## GitHub Thread Replies
When replying to inline review comments on GitHub, reply in the comment thread (`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`), not as a top-level PR comment.
## The Bottom Line
**External feedback = suggestions to evaluate, not orders to follow.**
Verify. Question. Then implement.
No performative agreement. Technical rigor always.

View File

@@ -0,0 +1,103 @@
---
name: requesting-code-review
description: Use when completing tasks, implementing major features, or before merging to verify work meets requirements
---
# Requesting Code Review
Dispatch a code reviewer subagent to catch issues before they cascade. The reviewer gets precisely crafted context for evaluation — never your session's history. This keeps the reviewer focused on the work product, not your thought process, and preserves your own context for continued work.
**Core principle:** Review early, review often.
## When to Request Review
**Mandatory:**
- After each task in subagent-driven development
- After completing major feature
- Before merge to main
**Optional but valuable:**
- When stuck (fresh perspective)
- Before refactoring (baseline check)
- After fixing complex bug
## How to Request
**1. Get git SHAs:**
```bash
BASE_SHA=$(git rev-parse HEAD~1) # or origin/main
HEAD_SHA=$(git rev-parse HEAD)
```
**2. Dispatch code reviewer subagent:**
Dispatch a `general-purpose` subagent, filling the template at [code-reviewer.md](code-reviewer.md)
**Placeholders:**
- `{DESCRIPTION}` - Brief summary of what you built
- `{PLAN_OR_REQUIREMENTS}` - What it should do
- `{BASE_SHA}` - Starting commit
- `{HEAD_SHA}` - Ending commit
**3. Act on feedback:**
- Fix Critical issues immediately
- Fix Important issues before proceeding
- Note Minor issues for later
- Push back if reviewer is wrong (with reasoning)
## Example
```
[Just completed Task 2: Add verification function]
You: Let me request code review before proceeding.
BASE_SHA=$(git log --oneline | grep "Task 1" | head -1 | awk '{print $1}')
HEAD_SHA=$(git rev-parse HEAD)
[Dispatch code reviewer subagent]
DESCRIPTION: Added verifyIndex() and repairIndex() with 4 issue types
PLAN_OR_REQUIREMENTS: Task 2 from docs/superpowers/plans/deployment-plan.md
BASE_SHA: a7981ec
HEAD_SHA: 3df7661
[Subagent returns]:
Strengths: Clean architecture, real tests
Issues:
Important: Missing progress indicators
Minor: Magic number (100) for reporting interval
Assessment: Ready to proceed
You: [Fix progress indicators]
[Continue to Task 3]
```
## Integration with Workflows
**Subagent-Driven Development:**
- Review after EACH task
- Catch issues before they compound
- Fix before moving to next task
**Executing Plans:**
- Review after each task or at natural checkpoints
- Get feedback, apply, continue
**Ad-Hoc Development:**
- Review before merge
- Review when stuck
## Red Flags
**Never:**
- Skip review because "it's simple"
- Ignore Critical issues
- Proceed with unfixed Important issues
- Argue with valid technical feedback
**If reviewer wrong:**
- Push back with technical reasoning
- Show code/tests that prove it works
- Request clarification
See template at: [code-reviewer.md](code-reviewer.md)

View File

@@ -0,0 +1,172 @@
# Code Reviewer Prompt Template
Use this template when dispatching a code reviewer subagent.
**Purpose:** Review completed work against requirements and code quality standards before it cascades into more work.
```
Subagent (general-purpose):
description: "Review code changes"
prompt: |
You are a Senior Code Reviewer with expertise in software architecture,
design patterns, and best practices. Your job is to review completed work
against its plan or requirements and identify issues before they cascade.
## What Was Implemented
[DESCRIPTION]
## Requirements / Plan
[PLAN_OR_REQUIREMENTS]
## Git Range to Review
**Base:** [BASE_SHA]
**Head:** [HEAD_SHA]
```bash
git diff --stat [BASE_SHA]..[HEAD_SHA]
git diff [BASE_SHA]..[HEAD_SHA]
```
## Read-Only Review
Your review is read-only on this checkout. Do not mutate the working tree, the index, HEAD, or branch state in any way. Use tools like `git show`, `git diff`, and `git log` to inspect history. If you need a working copy of a different revision, check it out into a separate temporary directory (e.g. `git worktree add /tmp/review-[SHA] [SHA]`) — never move HEAD on this checkout.
## What to Check
**Plan alignment:**
- Does the implementation match the plan / requirements?
- Are deviations justified improvements, or problematic departures?
- Is all planned functionality present?
**Code quality:**
- Clean separation of concerns?
- Proper error handling?
- Type safety where applicable?
- DRY without premature abstraction?
- Edge cases handled?
**Architecture:**
- Sound design decisions?
- Reasonable scalability and performance?
- Security concerns?
- Integrates cleanly with surrounding code?
**Testing:**
- Tests verify real behavior, not mocks?
- Edge cases covered?
- Integration tests where they matter?
- All tests passing?
**Production readiness:**
- Migration strategy if schema changed?
- Backward compatibility considered?
- Documentation complete?
- No obvious bugs?
## Calibration
Categorize issues by actual severity. Not everything is Critical.
Acknowledge what was done well before listing issues — accurate praise
helps the implementer trust the rest of the feedback.
If you find significant deviations from the plan, flag them specifically
so the implementer can confirm whether the deviation was intentional.
If you find issues with the plan itself rather than the implementation,
say so.
## Output Format
### Strengths
[What's well done? Be specific.]
### Issues
#### Critical (Must Fix)
[Bugs, security issues, data loss risks, broken functionality]
#### Important (Should Fix)
[Architecture problems, missing features, poor error handling, test gaps]
#### Minor (Nice to Have)
[Code style, optimization opportunities, documentation polish]
For each issue:
- File:line reference
- What's wrong
- Why it matters
- How to fix (if not obvious)
### Recommendations
[Improvements for code quality, architecture, or process]
### Assessment
**Ready to merge?** [Yes | No | With fixes]
**Reasoning:** [1-2 sentence technical assessment]
## Critical Rules
**DO:**
- Categorize by actual severity
- Be specific (file:line, not vague)
- Explain WHY each issue matters
- Acknowledge strengths
- Give a clear verdict
**DON'T:**
- Say "looks good" without checking
- Mark nitpicks as Critical
- Give feedback on code you didn't actually read
- Be vague ("improve error handling")
- Avoid giving a clear verdict
```
**Placeholders:**
- `[DESCRIPTION]` — brief summary of what was built
- `[PLAN_OR_REQUIREMENTS]` — what it should do (plan file path, task text, or requirements)
- `[BASE_SHA]` — starting commit
- `[HEAD_SHA]` — ending commit
**Reviewer returns:** Strengths, Issues (Critical / Important / Minor), Recommendations, Assessment
## Example Output
```
### Strengths
- Clean database schema with proper migrations (db.ts:15-42)
- Comprehensive test coverage (18 tests, all edge cases)
- Good error handling with fallbacks (summarizer.ts:85-92)
### Issues
#### Important
1. **Missing help text in CLI wrapper**
- File: index-conversations:1-31
- Issue: No --help flag, users won't discover --concurrency
- Fix: Add --help case with usage examples
2. **Date validation missing**
- File: search.ts:25-27
- Issue: Invalid dates silently return no results
- Fix: Validate ISO format, throw error with example
#### Minor
1. **Progress indicators**
- File: indexer.ts:130
- Issue: No "X of Y" counter for long operations
- Impact: Users don't know how long to wait
### Recommendations
- Add progress reporting for user experience
- Consider config file for excluded projects (portability)
### Assessment
**Ready to merge: With fixes**
**Reasoning:** Core implementation is solid with good architecture and tests. Important issues (help text, date validation) are easily fixed and don't affect core functionality.
```

View File

@@ -22,9 +22,28 @@ RUN_MIGRATIONS_ON_START=false
# 对外端口(宝塔/Nginx 反代推荐只暴露前端API 经反向代理访问)
BIND_ADDR=127.0.0.1
PLAYER_PORT=8082
ADMIN_PORT=8081
# ── 四套 player 镜像 tag 与宿主机端口 ──────────────────────────
# main暗金主站
PLAYER_IMAGE_TAG=main
PLAYER_PORT=8082
# theme-2Pinnacle 蓝白)
PLAYER2_IMAGE_TAG=theme-2
PLAYER2_PORT=8083
# theme-3统一移动端视觉
PLAYER3_IMAGE_TAG=theme-3
PLAYER3_PORT=8084
# theme-4海军蓝暗色极简
PLAYER4_IMAGE_TAG=theme-4
PLAYER4_PORT=8085
# CORS 白名单:四个玩家站域名 + 管理后台(逗号分隔,勿含空格,勿带末尾 /
# CORS_ORIGINS=https://www.example.com,https://theme2.example.com,https://theme3.example.com,https://theme4.example.com,https://admin.example.com
# 管理端构建时注入:生成邀请注册链接的玩家端公网地址(勿带末尾 /
VITE_PLAYER_URL=https://www.thebet365.net

View File

@@ -5,6 +5,7 @@ JWT_PLAYER_EXPIRES=24h
JWT_ADMIN_EXPIRES=2h
JWT_AGENT_EXPIRES=8h
PORT=3000
# Windows + Hyper-V/WSL 若 3000 报 EACCES可改为 3100apps/api/.env 本地配置,勿提交)
NODE_ENV=development
UPLOAD_DIR=

6
.gitignore vendored
View File

@@ -8,6 +8,12 @@ docker-build.log
thebet365-images.tar
thebet365-images-*.tar
thebet365-images-*.manifest.txt
thebet365-full-themes-*.tar
thebet365-full-themes-*.manifest.txt
thebet365-player-*.tar
thebet365-player-*.manifest.txt
thebet365-admin-latest.tar
thebet365-admin-latest.manifest.txt
.claude/
*.log
.DS_Store

242
AGENTS.md
View File

@@ -1,46 +1,212 @@
# 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`.
## 目录
## 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`.
## 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`.
| 章节 | 何时查 |
| ------------------------- | ----------------------- |
| [运行环境与仓库结构](#运行环境与仓库结构) | 不确定 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-改代码时请避免) | 常见踩坑清单 |
## 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`.
> **AI 会读吗?** Cursor 等工具会在对话开始时把 `AGENTS.md` 注入上下文;当前约 200 行,一般能整篇读入。文件继续变长时,索引有助于人和 AI 快速定位章节;细节仍以 `docs/` 为准,此处只写约定与指针。
## 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.
## 运行环境与仓库结构
- 使用 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` | 玩家端性能验收 |
| | |
## 部署注意
- 生产 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 {
}

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

@@ -0,0 +1,86 @@
/* 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']
LeagueRowActions: typeof import('./src/components/LeagueRowActions.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

@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="manifest" href="/site.webmanifest" />

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,99 +43,29 @@ 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;
touch-action: manipulation;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@media (max-width: 1023px) {
input,
select,
textarea,
.el-input__inner,
.el-textarea__inner {
font-size: 16px !important;
}
}
html::-webkit-scrollbar,
body::-webkit-scrollbar {
@@ -143,39 +73,15 @@ 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;
height: 100%;
min-height: 0;
overflow: hidden;
gap: 18px;
}
.admin-list-page > .page-toolbar,
.admin-list-page > .filter-card,
@@ -189,16 +95,16 @@ body::-webkit-scrollbar {
justify-content: flex-end;
align-items: center;
gap: 8px;
margin: 0 0 8px;
margin: 0;
}
.admin-list-page > .tool-card {
margin-bottom: 8px;
margin-bottom: 0;
}
.admin-list-page > .filter-card {
margin-bottom: 8px;
margin-bottom: 0;
}
.admin-list-page > .list-chrome {
margin-bottom: 8px;
margin-bottom: 0;
display: flex;
flex-direction: column;
gap: 6px;
@@ -211,8 +117,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;
}
@@ -315,7 +221,7 @@ body::-webkit-scrollbar {
margin-right: 0;
}
.admin-list-page > .list-settings {
margin-bottom: 8px;
margin-bottom: 0;
}
.list-settings :deep(.el-collapse-item__header) {
height: 36px;
@@ -323,11 +229,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 +241,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,
@@ -354,14 +260,64 @@ body::-webkit-scrollbar {
.admin-list-page > .list-panel {
border: 1px solid var(--border);
background: var(--bg-card);
padding: 0 10px 10px;
padding: 14px 16px 16px;
}
/* Tabs / 筛选壳内的 list-panel 不再套第二层卡片,避免顶边重叠 */
.admin-list-page > .mgr-tabs-shell .list-panel,
.admin-list-page .el-tab-pane > .list-panel,
.list-chrome > .list-panel {
border: none;
box-shadow: none;
background: transparent;
padding: 0;
border-radius: 0;
overflow: visible;
}
/* 代理管理等 Tabs 页:外层壳作为唯一卡片 */
.admin-list-page > .mgr-tabs-shell {
flex: 1;
min-height: 0;
border: 1px solid var(--border);
border-radius: 10px;
background: #ffffff;
box-shadow: var(--shadow);
padding: 14px 16px 16px;
}
.admin-list-page > .mgr-tabs-shell .mgr-top-tabs .el-tabs__header {
margin-bottom: 14px;
}
.admin-list-page > .filter-card .el-card__body {
padding: 16px 18px 18px;
}
.admin-list-page > .filter-card .el-tabs__header {
margin-bottom: 14px;
}
.list-chrome__row + .list-hint {
margin-top: 2px;
padding-top: 4px;
padding-bottom: 0;
}
.admin-list-page > .data-card .el-card__body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 12px 12px;
padding: 12px;
}
/* data-card 内不再嵌套第二层卡片边框,避免双层顶边重合 */
.admin-list-page > .data-card .table-wrap,
.admin-list-page > .data-card .admin-table-wrap {
border: none;
border-radius: 0;
box-shadow: none;
background: transparent;
}
.admin-list-page .table-wrap {
flex: 1;
@@ -386,10 +342,43 @@ body::-webkit-scrollbar {
.admin-list-page .list-hint {
flex-shrink: 0;
margin: 0;
padding: 6px 0 8px;
padding: 4px 0 12px;
font-size: 11px;
color: #666;
line-height: 1.4;
color: var(--text-muted);
line-height: 1.45;
}
.admin-list-page .list-panel-toolbar + .list-hint {
padding-top: 0;
margin-top: -2px;
}
.admin-list-page .list-panel-toolbar {
flex-shrink: 0;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 2px 0 14px;
margin-bottom: 2px;
border-bottom: 1px solid var(--border-soft);
--list-chrome-control-h: 32px;
--el-component-size: 32px;
}
.admin-list-page .list-panel-toolbar :deep(.el-form-item) {
margin-bottom: 0 !important;
margin-right: 12px;
}
.admin-list-page .list-panel-toolbar :deep(.el-input__wrapper),
.admin-list-page .list-panel-toolbar :deep(.el-select__wrapper) {
height: var(--list-chrome-control-h) !important;
min-height: var(--list-chrome-control-h) !important;
}
.admin-list-page .list-panel-toolbar :deep(.el-button:not(.is-link)) {
height: var(--list-chrome-control-h) !important;
min-height: var(--list-chrome-control-h) !important;
}
.admin-list-page .pager {
flex-shrink: 0;
@@ -399,7 +388,7 @@ body::-webkit-scrollbar {
padding-top: 0;
}
/* 控制台等非列表页:允许内部滚动(滚动条已全局隐藏) */
/* 控制台等非列表页 */
.dashboard-page,
.page-scroll {
height: 100%;
@@ -416,350 +405,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 +418,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 +432,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 +443,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 +455,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 +482,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 +490,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 +521,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 +783,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 +1062,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;
@@ -1670,7 +1340,7 @@ input:-webkit-autofill:focus {
.admin-list-page {
display: flex;
flex-direction: column;
gap: 16px;
gap: 18px;
color: var(--text);
}
@@ -1701,8 +1371,11 @@ input:-webkit-autofill:focus {
.list-toolbar + .table-wrap,
.filter-bar + .table-wrap,
.filters + .table-wrap,
.list-panel-toolbar + .table-wrap {
margin-top: 2px;
.list-panel-toolbar + .table-wrap,
.list-panel-toolbar + .admin-table-wrap,
.list-hint + .table-wrap,
.list-hint + .admin-table-wrap {
margin-top: 0;
}
.table-wrap,
@@ -1721,8 +1394,9 @@ input:-webkit-autofill:focus {
.list-chrome > .admin-table-wrap,
.list-chrome > .table-panel,
.list-chrome > .list-panel {
border-color: var(--border-soft);
border: none;
box-shadow: none;
background: transparent;
}
.table-scroll,

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,325 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import { fetchMediaLibraryImages } from '../utils/media-library';
import { resolveApiError } from '../i18n/form-validation';
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; category?: 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 {
mediaFiles.value = await fetchMediaLibraryImages({ pageSize: 200 });
} catch (err: unknown) {
mediaFiles.value = [];
ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed'));
} 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
:z-index="4000"
>
<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 v-if="file.category" class="media-category">{{ file.category }}</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 2px;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-category {
padding: 0 8px 6px;
font-size: 10px;
color: var(--text-muted);
opacity: 0.85;
}
</style>

View File

@@ -0,0 +1,540 @@
<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';
import { fetchMediaLibraryImages } from '../utils/media-library';
import { resolveApiError } from '../i18n/form-validation';
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; category?: 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 {
mediaFiles.value = await fetchMediaLibraryImages({ pageSize: 200 });
} catch (err: unknown) {
mediaFiles.value = [];
ElMessage.error(resolveApiError(err, t, 'content.upload.load_media_failed'));
} 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
:z-index="4000"
>
<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 v-if="file.category" class="media-category">{{ file.category }}</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 2px;
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-category {
padding: 0 8px 6px;
font-size: 10px;
color: var(--text-muted);
opacity: 0.85;
}
</style>

View File

@@ -0,0 +1,104 @@
<script setup lang="ts">
export interface LeagueRowView {
id: string;
isPublished: boolean;
isOutrightSettled?: boolean;
isPublishing: boolean;
labels: {
edit: string;
createFixture: string;
publish: string;
unpublish: string;
delete: string;
};
}
defineProps<{
row: LeagueRowView;
}>();
defineEmits<{
edit: [];
createFixture: [];
togglePublish: [];
archive: [];
}>();
</script>
<template>
<div class="league-row-actions">
<div class="league-action-group">
<el-button size="small" type="primary" @click.stop="$emit('edit')">
{{ row.labels.edit }}
</el-button>
<el-button
v-if="!row.isOutrightSettled"
size="small"
type="primary"
@click.stop="$emit('createFixture')"
>
{{ row.labels.createFixture }}
</el-button>
<el-button
v-if="!row.isPublished"
size="small"
type="success"
:loading="row.isPublishing"
@click.stop="$emit('togglePublish')"
>
{{ row.labels.publish }}
</el-button>
<el-button
v-else-if="!row.isOutrightSettled"
size="small"
type="warning"
:loading="row.isPublishing"
@click.stop="$emit('togglePublish')"
>
{{ row.labels.unpublish }}
</el-button>
<el-button size="small" type="danger" plain @click.stop="$emit('archive')">
{{ row.labels.delete }}
</el-button>
</div>
</div>
</template>
<style scoped>
.league-row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 6px 8px;
flex-wrap: wrap;
}
.league-action-group {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
padding: 2px 4px;
border-radius: 8px;
background: #fbfaf7;
border: 1px solid var(--border-soft);
}
.league-row-actions :deep(.el-button) {
margin: 0 !important;
min-width: 52px;
min-height: 26px;
padding: 4px 10px !important;
font-size: 12px !important;
font-weight: 700;
border-radius: 6px;
}
.league-row-actions :deep(.el-button:not(.is-disabled):not(:disabled)) {
cursor: pointer;
}
.league-row-actions :deep(.el-button.is-disabled),
.league-row-actions :deep(.el-button:disabled) {
cursor: not-allowed;
}
</style>

View File

@@ -4,8 +4,9 @@ import { useAuthStore } from '../stores/auth';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import AdminTableEmpty from './AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { walletDepositMethodLabel, walletTxTypeKey } from '../utils/walletTx';
import { formatAmountFull } from '../utils/format-amount';
import { formatAdminDateTimeBrief, formatAdminDateTimeFull } from '../utils/format-datetime';
import { walletDepositMethodLabel, walletTxTypeKey, txDisplayAmount, walletRemarkLabel } from '../utils/walletTx';
interface WalletTxRow {
id: string;
@@ -34,7 +35,7 @@ const emit = defineEmits<{
'update:modelValue': [value: boolean];
}>();
const { t, locale, localeTag } = useAdminLocale();
const { t, locale } = useAdminLocale();
const auth = useAuthStore();
const visible = computed({
@@ -68,17 +69,6 @@ function depositMethodLabel(row: WalletTxRow) {
return walletDepositMethodLabel(row, t);
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
function dateParams() {
if (!dateRange.value?.length) return {};
const [from, to] = dateRange.value;
@@ -139,7 +129,7 @@ watch(
<el-dialog
v-model="visible"
:title="dialogTitle"
width="1080px"
width="1250px"
destroy-on-close
class="player-wallet-ledger-dialog"
append-to-body
@@ -172,8 +162,12 @@ watch(
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column :label="t('audit.col.time')" min-width="150">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
<el-table-column :label="t('audit.col.time')" min-width="88">
<template #default="{ row }">
<el-tooltip :content="formatAdminDateTimeFull(row.createdAt, locale)" placement="top">
<span>{{ formatAdminDateTimeBrief(row.createdAt, locale) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.tx_id')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.transactionId }}</template>
@@ -181,47 +175,29 @@ watch(
<el-table-column :label="t('finance.col.tx_type')" min-width="80">
<template #default="{ row }">{{ walletTypeLabel(row.transactionType) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.deposit_method')" min-width="120" show-overflow-tooltip>
<el-table-column :label="t('finance.col.deposit_method')" min-width="72" show-overflow-tooltip>
<template #default="{ row }">{{ depositMethodLabel(row) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_change')" min-width="96" align="right">
<el-table-column :label="t('finance.col.balance_change')" min-width="110" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.amount)" placement="top">
<span :class="parseFloat(row.amount) >= 0 ? 'amt-pos' : 'amt-neg'">
{{ formatAmount(row.amount) }}
</span>
</el-tooltip>
<span :class="parseFloat(txDisplayAmount(row)) >= 0 ? 'amt-pos' : 'amt-neg'">
{{ formatAmountFull(txDisplayAmount(row)) }}
</span>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_before')" min-width="92" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.balanceBefore)" placement="top">
<span>{{ formatAmount(row.balanceBefore) }}</span>
</el-tooltip>
</template>
<el-table-column :label="t('finance.col.balance_before')" min-width="110" align="right">
<template #default="{ row }">{{ formatAmountFull(row.balanceBefore) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.balance_after')" min-width="92" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.balanceAfter)" placement="top">
<span>{{ formatAmount(row.balanceAfter) }}</span>
</el-tooltip>
</template>
<el-table-column :label="t('finance.col.balance_after')" min-width="110" align="right">
<template #default="{ row }">{{ formatAmountFull(row.balanceAfter) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.frozen_before')" min-width="92" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.frozenBefore)" placement="top">
<span>{{ formatAmount(row.frozenBefore) }}</span>
</el-tooltip>
</template>
<el-table-column :label="t('finance.col.frozen_before')" min-width="110" align="right">
<template #default="{ row }">{{ formatAmountFull(row.frozenBefore) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.frozen_after')" min-width="92" align="right">
<template #default="{ row }">
<el-tooltip :content="formatAmountFull(row.frozenAfter)" placement="top">
<span>{{ formatAmount(row.frozenAfter) }}</span>
</el-tooltip>
</template>
<el-table-column :label="t('finance.col.frozen_after')" min-width="110" align="right">
<template #default="{ row }">{{ formatAmountFull(row.frozenAfter) }}</template>
</el-table-column>
<el-table-column :label="t('finance.col.reference')" min-width="110" show-overflow-tooltip>
<el-table-column :label="t('finance.col.reference')" min-width="105" show-overflow-tooltip>
<template #default="{ row }">
<router-link
v-if="row.betNo"
@@ -234,11 +210,11 @@ watch(
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="88">
<el-table-column :label="t('agent.credit_tx.col.operator')" min-width="85">
<template #default="{ row }">{{ row.operatorUsername ?? '—' }}</template>
</el-table-column>
<el-table-column :label="t('user.field.remark')" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark ?? '—' }}</template>
<el-table-column :label="t('user.field.remark')" min-width="180">
<template #default="{ row }">{{ walletRemarkLabel(row.remark, row.transactionType, t) }}</template>
</el-table-column>
</el-table>
</div>
@@ -298,6 +274,10 @@ watch(
</style>
<style>
.player-wallet-ledger-dialog {
max-width: 95vw;
}
.player-wallet-ledger-dialog .el-dialog__header {
padding: 12px 16px 6px;
margin-right: 0;

View File

@@ -0,0 +1,24 @@
import type { InjectionKey, Ref } from 'vue';
import type { PlayerRow } from '../views/user-form';
export type AgentPlayerActions = {
canCreatePlayer: boolean;
openCreatePlayer: (parentAgentUserId: string) => void;
openDetailPlayer: (id: string) => void;
openEditPlayer: (id: string) => void;
openTransfer: (type: 'deposit' | 'withdraw', row: { id: string; username?: string }) => void;
toggleFreezePlayer: (row: PlayerRow) => void | Promise<void>;
deletePlayer: (row: PlayerRow) => void | Promise<void>;
openPlayerWalletLedger: (playerId: string, playerUsername?: string | null) => void;
playerActionFlags: {
showEdit: boolean;
showDeposit: boolean;
showWithdraw: boolean;
showFreeze: boolean;
showDelete: boolean;
};
};
export const agentPlayerActionsKey: InjectionKey<AgentPlayerActions> = Symbol('agentPlayerActions');
export const agentDirectPlayersReloadKey: InjectionKey<Ref<(() => void) | null>> =
Symbol('agentDirectPlayersReload');

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': '确认停用此收款方式?',
@@ -139,6 +140,9 @@ const zh: Record<string, string> = {
'breadcrumb.settlement': '赛事结算',
'breadcrumb.match_edit': '编辑赛事',
'breadcrumb.match_markets': '盘口管理',
'breadcrumb.league_fixtures': '单场赛事',
'breadcrumb.league_outrights': '优胜冠军',
'breadcrumb.agent_direct_players': '直属玩家',
'breadcrumb.outright_edit': '编辑优胜冠军',
'role.admin': '系统管理员',
'role.super_admin': '超级管理员',
@@ -150,11 +154,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 +169,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 +227,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 +321,15 @@ const zh: Record<string, string> = {
'match.status.CLOSED': '已封盘',
'match.status.SETTLED': '已结算',
'match.status.PENDING_SETTLEMENT': '待结算',
...adminPagesZh,
'match.filter.has_bets': '仅显示有下注',
'match.filter.kickoff_from': '开赛起',
'match.filter.kickoff_to': '开赛止',
'match.sort.default': '默认排序',
'match.sort.kickoff_asc': '按开赛时间(早→晚)',
'match.sort.kickoff_desc': '按开赛时间(晚→早)',
'match.sort.bet_count': '按注单数',
'match.sort.total_stake': '按投注额',
// 列表页/弹窗文案通过 bundles/zh-CN 动态平载,不在此处静态 spread
};
const en: Record<string, string> = {
@@ -334,6 +354,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 +391,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',
@@ -438,6 +460,9 @@ const en: Record<string, string> = {
'breadcrumb.settlement': 'Settlement',
'breadcrumb.match_edit': 'Edit match',
'breadcrumb.match_markets': 'Markets',
'breadcrumb.league_fixtures': 'Fixtures',
'breadcrumb.league_outrights': 'Outright odds',
'breadcrumb.agent_direct_players': 'Direct players',
'breadcrumb.outright_edit': 'Edit outright',
'role.admin': 'Administrator',
'role.super_admin': 'Super admin',
@@ -449,11 +474,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 +489,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 +547,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 +641,15 @@ const en: Record<string, string> = {
'match.status.CLOSED': 'Closed',
'match.status.SETTLED': 'Settled',
'match.status.PENDING_SETTLEMENT': 'Pending settlement',
...adminPagesEn,
'match.filter.has_bets': 'Show Placed Bets Only',
'match.filter.kickoff_from': 'Kickoff from',
'match.filter.kickoff_to': 'Kickoff to',
'match.sort.default': 'Default Order',
'match.sort.kickoff_asc': 'By kickoff (earliest first)',
'match.sort.kickoff_desc': 'By kickoff (latest first)',
'match.sort.bet_count': 'By Bet Count',
'match.sort.total_stake': 'By Stake Amount',
// 列表页/弹窗文案通过 bundles/en-US 动态平载,不在此处静态 spread
};
const ms: Record<string, string> = {
@@ -633,6 +674,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 +711,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',
@@ -737,6 +780,9 @@ const ms: Record<string, string> = {
'breadcrumb.settlement': 'Penyelesaian',
'breadcrumb.match_edit': 'Edit perlawanan',
'breadcrumb.match_markets': 'Pasaran',
'breadcrumb.league_fixtures': 'Perlawanan',
'breadcrumb.league_outrights': 'Odds juara',
'breadcrumb.agent_direct_players': 'Pemain langsung',
'breadcrumb.outright_edit': 'Edit juara',
'role.admin': 'Pentadbir',
'role.super_admin': 'Super pentadbir',
@@ -748,11 +794,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 +809,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 +867,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 +961,15 @@ const ms: Record<string, string> = {
'match.status.CLOSED': 'Ditutup',
'match.status.SETTLED': 'Diselesaikan',
'match.status.PENDING_SETTLEMENT': 'Menunggu penyelesaian',
...adminPagesMs,
'match.filter.has_bets': 'Tunjukkan Pertaruhan Sahaja',
'match.filter.kickoff_from': 'Mula dari',
'match.filter.kickoff_to': 'Mula hingga',
'match.sort.default': 'Susunan Lalai',
'match.sort.kickoff_asc': 'Ikut masa mula (awal→lewat)',
'match.sort.kickoff_desc': 'Ikut masa mula (lewat→awal)',
'match.sort.bet_count': 'Ikut Bil. Pertaruhan',
'match.sort.total_stake': 'Ikut Jumlah Taruhan',
// 列表页/弹窗文案通过 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',
@@ -130,6 +134,7 @@ export const adminPagesMs: Record<string, string> = {
'agent.col.credit': 'Had / Digunakan / Tersedia',
'agent.col.direct_players': 'Pemain terus',
'agent.direct_players_title': 'Pemain terus · {name}',
'agent.open_agent_hint': 'Klik baris ejen untuk buka halaman pemain terus.',
'agent.platform_row_name': 'Platform',
'agent.col.sub_agents': 'Sub-ejen',
'agent.col.cashback': 'Kadar rebat',
@@ -226,6 +231,9 @@ export const adminPagesMs: Record<string, string> = {
'finance.remark.admin_deposit': 'Deposit admin',
'finance.remark.admin_withdraw': 'Pengeluaran admin',
'finance.remark.initial_balance': 'Baki permulaan akaun',
'finance.remark.revoke_deposit': 'Deposit diluluskan dibatalkan {orderNo}',
'finance.remark.deposit_order': 'Pesanan deposit {orderNo}',
'finance.remark.cashback_batch': 'Kumpulan cashback {batchNo}',
'agent.col.no_records': 'Tiada rekod',
'agent.btn.confirm_adjust': 'Sahkan',
'agent.field.select_user': 'Pilih pengguna',
@@ -273,6 +281,8 @@ export const adminPagesMs: Record<string, string> = {
'match.hint.create_league': 'Kejohanan baharu tidak diterbitkan secara lalai; terbitkan untuk paparan pemain, kemudian kembangkan untuk tambah perlawanan.',
'league.status.PUBLISHED': 'Diterbitkan',
'league.status.UNPUBLISHED': 'Tidak diterbitkan',
'league.status.OUTRIGHT_SETTLED': 'Kejohanan diselesaikan',
'league.hint.outright_settled_no_fixture': 'Pasaran juara telah diselesaikan; perlawanan baharu tidak boleh ditambah.',
'league.btn.unpublish': 'Nyahterbit',
'league.confirm_unpublish': 'Pemain tidak lagi melihat kejohanan ini; anda masih boleh edit dan terbitkan semula di admin. Teruskan?',
'msg.league_published': 'Kejohanan diterbitkan',
@@ -283,6 +293,10 @@ export const adminPagesMs: Record<string, string> = {
'match.hint.edit_published': 'Diterbitkan: edit masa mula, pilihan utama, nama paparan; tertutup/selesai dikunci.',
'match.expand_league_hint': 'Kembangkan liga untuk urus perlawanan; odds juara di tab Odds juara.',
'match.expand_outright_hint': 'Kembangkan liga untuk sunting odds juara; pasukan perlawanan disegerakkan auto, boleh tambah pasukan belum dijadualkan.',
'match.open_league_hint': 'Klik baris liga untuk buka halaman urus perlawanan.',
'match.open_outright_hint': 'Klik baris liga untuk buka halaman odds juara.',
'match.league_fixtures_subtitle': 'Perlawanan',
'match.league_outrights_subtitle': 'Odds juara',
'outright.odds_only_hint': 'Pasukan daripada perlawanan disegerakkan auto; boleh tambah pasukan manual dan sunting odds di sini. Pasaran juara ikut terbitan liga — tiada langkah terbit berasingan.',
'outright.league_unpublished_hint': 'Liga belum diterbitkan. Tetapkan liga kepada Diterbitkan di halaman ini untuk membuka pertaruhan juara secara automatik.',
'outright.unsettled_fixtures_hint': '{n} perlawanan dalam liga ini masih belum diselesaikan. Selesaikan dahulu sebelum juara.',
@@ -351,6 +365,7 @@ export const adminPagesMs: Record<string, string> = {
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': 'Kemas kini tetapan akaun pemain',
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': 'Kemas kini tetapan penggantungan ejen',
'audit.action.UPDATE_AGENT_HIERARCHY_SETTINGS': 'Kemas kini tetapan hierarki ejen',
'audit.action.UPDATE_PLATFORM_DIRECT_CASHBACK_SETTINGS': 'Kemas kini tetapan rebat platform',
'audit.action.UPDATE_BETTING_LIMITS': 'Kemas kini had pertaruhan',
'audit.action.CONFIRM_SETTLEMENT': 'Sahkan penyelesaian',
'audit.action.CONFIRM_RESETTLE': 'Sahkan penyelesaian semula',
@@ -358,6 +373,7 @@ export const adminPagesMs: Record<string, string> = {
'audit.action.CANCEL_CASHBACK': 'Batalkan kelompok rebat',
'audit.action.CREATE_STAFF': 'Cipta kakitangan',
'audit.action.UPDATE_STAFF': 'Kemas kini kakitangan',
'audit.action.DELETE_STAFF': 'Padam kakitangan',
'audit.action.PURGE_UNUSED_FILES': 'Padam media tidak digunakan',
'audit.action.FORGOT_PASSWORD_RESET': 'Pemain set semula kata laluan',
'audit.action.CREATE_LEAGUE': 'Cipta liga',
@@ -609,6 +625,7 @@ export const adminPagesMs: Record<string, string> = {
'settlement.record_score': 'Simpan skor',
'settlement.preview_hint': 'Pratonton menukar perlawanan ke menunggu penyelesaian (skor disimpan selepas pengesahan; boleh buka semula sebelum itu)',
'settlement.preview_btn': 'Pratonton penyelesaian',
'settlement.view_preview_btn': 'Lihat pratonton penyelesaian',
'settlement.preview_failed': 'Gagal menjana pratonton penyelesaian',
'settlement.err_score_not_recorded': 'Sila masukkan skor separuh masa dan penuh masa sebelum penyelesaian',
'settlement.must_close_first': 'Tutup pertaruhan sebelum penyelesaian',
@@ -643,6 +660,33 @@ export const adminPagesMs: Record<string, string> = {
'settlement.smart.strategy.TARGET_HOLD': 'Sasaran pegangan',
'msg.score_recorded': 'Skor disimpan',
'msg.settlement_confirmed': 'Penyelesaian disahkan',
'settlement.resettle_reason': 'Sebab penyelesaian semula',
'settlement.resettle_preview': 'Pratonton penyelesaian semula',
'settlement.resettle_preview_title': 'Pratonton penyelesaian semula',
'settlement.resettle_affected': 'Pertaruhan terpengaruh',
'settlement.resettle_topup': 'Bayaran tambahan diperlukan',
'settlement.resettle_clawback': 'Bayaran balik diperlukan',
'settlement.resettle_confirm': 'Sahkan penyelesaian semula',
'settlement.resettle_affected_list': 'Butiran Pertaruhan Terpengaruh',
'settlement.resettle_col.old_result': 'Keputusan/Bayaran Asal',
'settlement.resettle_col.new_result': 'Keputusan/Bayaran Baharu',
'settlement.resettle_col.adjust': 'Pelarasan',
'settlement.history_tab': 'Rekod Penyelesaian',
'settlement.history_tab_bets': 'Statistik Pertaruhan',
'settlement.history.no_records': 'Tiada rekod penyelesaian',
'settlement.history.col.batch_no': 'No. Kumpulan',
'settlement.history.col.type': 'Jenis',
'settlement.history.col.score': 'Skor (HT/FT)',
'settlement.history.col.corners': 'Sepakan sudut (R/T)',
'settlement.history.col.cards': 'Kad kuning/merah (R/T)',
'settlement.history.col.total_bets': 'Pertaruhan diselesaikan',
'settlement.history.col.total_payout': 'Jumlah bayaran',
'settlement.history.col.total_refund': 'Jumlah bayaran balik',
'settlement.history.col.operator': 'Pengendali',
'settlement.history.col.time': 'Masa diselesaikan',
'settlement.history.col.reason': 'Sebab',
'settlement.history.type.initial': 'Penyelesaian awal',
'settlement.history.type.resettle': 'Penyelesaian semula',
'agent_portal.create_player_section': 'Cipta pemain',
'agent_portal.deposit_section': 'Tambah baki',
@@ -765,11 +809,62 @@ 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.inbox_broadcast.title': 'Hantar manual',
'content.inbox_broadcast.hint': 'Hantar mesej peti secara manual kepada pemain. Padam rekod juga membuang mesej di peti pemain.',
'content.inbox_broadcast.field_title': 'Tajuk',
'content.inbox_broadcast.field_body': 'Kandungan',
'content.inbox_broadcast.field_target': 'Penerima',
'content.inbox_broadcast.field_username': 'Nama pengguna pemain',
'content.inbox_broadcast.target_all': 'Semua pemain',
'content.inbox_broadcast.target_user': 'Pemain tertentu',
'content.inbox_broadcast.username_placeholder': 'Masukkan nama log masuk pemain',
'content.inbox_broadcast.send': 'Hantar',
'content.inbox_broadcast.send_success': 'Dihantar kepada {n} pemain',
'content.inbox_broadcast.form_invalid': 'Sila isi tajuk atau kandungan sekurang-kurangnya satu bahasa',
'content.inbox_broadcast.target_user_required': 'Nama pengguna pemain diperlukan',
'content.inbox_broadcast.history_title': 'Sejarah penghantaran',
'content.inbox_broadcast.view_title': 'Butiran penghantaran',
'content.inbox_broadcast.col_title': 'Tajuk',
'content.inbox_broadcast.col_target': 'Sasaran',
'content.inbox_broadcast.col_recipients': 'Bilangan',
'content.inbox_broadcast.col_sender': 'Penghantar',
'content.inbox_broadcast.col_time': 'Masa',
'content.inbox_broadcast.delete_confirm': 'Padam "{title}"? Salinan di peti pemain juga akan dibuang.',
'content.inbox_broadcast.locale_fallback_hint': 'Locale yang kosong akan guna susunan: bahasa pemain → Inggeris → Cina Ringkas → Melayu.',
'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.upload.load_media_failed': 'Gagal memuat pustaka media',
'content.status.DRAFT': 'Draf',
'content.status.ACTIVE': 'Aktif',
'content.status.INACTIVE': 'Tidak aktif',
@@ -783,6 +878,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 +893,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',
@@ -960,6 +1056,15 @@ export const adminPagesMs: Record<string, string> = {
'media.no_files': 'Tiada fail lagi',
'media.refresh': 'Muat Semula',
'media.unused_count': '{n} tidak digunakan',
'media.storage_stats': 'Statistik Storan',
'media.deposits_on_disk': 'Tangkapan Skrin Deposit (Tidak dipetakan ke Media)',
'media.screenshot_cleanup': 'Pembersihan Tangkapan Skrin',
'media.cleanup_auto_enabled': 'Pembersihan Auto Dinonaktifkan/Diaktifkan',
'media.cleanup_keep_days': 'Tempoh Penyimpanan (Hari)',
'media.cleanup_before_date': 'Tarikh Akhir Pembersihan Manual',
'media.cleanup_run_now': 'Bersihkan Sekarang',
'media.cleanup_result': 'Berjaya membersihkan {cleaned} tangkapan skrin, membebaskan storan sebanyak {size}.',
'media.cleanup_expired_tag': 'Dibersihkan',
};
export default adminPagesMs;

View File

@@ -237,6 +237,9 @@ export const adminPagesZh: Record<string, string> = {
'finance.remark.admin_deposit': '管理员上分',
'finance.remark.admin_withdraw': '管理员下分',
'finance.remark.initial_balance': '开户初始余额',
'finance.remark.revoke_deposit': '撤销已通过充值 {orderNo}',
'finance.remark.deposit_order': '充值订单 {orderNo}',
'finance.remark.cashback_batch': '返水批次 {batchNo}',
'agent.col.no_records': '暂无记录',
'agent.btn.confirm_adjust': '确认调整',
'agent.field.select_user': '选择用户',
@@ -295,6 +298,8 @@ export const adminPagesZh: Record<string, string> = {
'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。',
'league.status.PUBLISHED': '已发布',
'league.status.UNPUBLISHED': '未发布',
'league.status.OUTRIGHT_SETTLED': '赛事已结算',
'league.hint.outright_settled_no_fixture': '优胜赛已结算,不可再新增单场。',
'league.btn.unpublish': '下架',
'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?',
'msg.league_published': '联赛已发布',
@@ -838,15 +843,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 +880,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 +891,9 @@ 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.load_media_failed': '加载媒体库失败',
'content.upload.url_placeholder': '或手动粘贴图片 URL',
'content.upload.recommended_size': '建议尺寸860 x 360 px或 43:18 同比例图片;前台会完整显示并自动填充不合比例区域。',
'content.link.none': '无跳转',
@@ -1069,6 +1093,14 @@ export const adminPagesZh: Record<string, string> = {
'media.no_files': '暂无文件',
'media.refresh': '刷新',
'media.unused_count': '{n} 个未使用',
'media.storage_stats': '存储空间统计',
'media.deposits_on_disk': '充值订单截图 (未挂靠媒体库)',
'media.screenshot_cleanup': '充值截图清理',
'media.cleanup_auto_enabled': '开启自动定期清理',
'media.cleanup_keep_days': '截图保留天数',
'media.cleanup_before_date': '手动清理截止日期',
'media.cleanup_run_now': '立即清理',
'media.cleanup_result': '成功清理了 {cleaned} 张截图,释放了 {size} 空间。',
};
export const adminPagesEn: Record<string, string> = {
@@ -1309,6 +1341,9 @@ export const adminPagesEn: Record<string, string> = {
'finance.remark.admin_deposit': 'Admin deposit',
'finance.remark.admin_withdraw': 'Admin withdraw',
'finance.remark.initial_balance': 'Initial account balance',
'finance.remark.revoke_deposit': 'Revoked approved deposit {orderNo}',
'finance.remark.deposit_order': 'Deposit order {orderNo}',
'finance.remark.cashback_batch': 'Cashback batch {batchNo}',
'agent.col.no_records': 'No records',
'agent.btn.confirm_adjust': 'Confirm',
'agent.field.select_user': 'Select user',
@@ -1367,6 +1402,8 @@ export const adminPagesEn: Record<string, string> = {
'match.hint.create_league': 'New tournaments are unpublished by default; use Publish in the list for player visibility, then expand to add fixtures.',
'league.status.PUBLISHED': 'Published',
'league.status.UNPUBLISHED': 'Unpublished',
'league.status.OUTRIGHT_SETTLED': 'Tournament settled',
'league.hint.outright_settled_no_fixture': 'Outright market is settled; new fixtures cannot be added.',
'league.btn.unpublish': 'Unpublish',
'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?',
'msg.league_published': 'Tournament published',
@@ -1911,11 +1948,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 +1985,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 +1996,9 @@ 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.load_media_failed': 'Failed to load media library',
'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',
@@ -2142,4 +2198,12 @@ export const adminPagesEn: Record<string, string> = {
'media.no_files': 'No files yet',
'media.refresh': 'Refresh',
'media.unused_count': '{n} unused',
'media.storage_stats': 'Storage Statistics',
'media.deposits_on_disk': 'Recharge Screenshots (Unmapped to Media Library)',
'media.screenshot_cleanup': 'Screenshot Cleanup',
'media.cleanup_auto_enabled': 'Enable Auto-cleanup',
'media.cleanup_keep_days': 'Retention Days',
'media.cleanup_before_date': 'Manual Cleanup Before Date',
'media.cleanup_run_now': 'Clean Now',
'media.cleanup_result': 'Successfully cleaned {cleaned} screenshot(s), freeing {size} of space.',
};

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',
@@ -136,6 +140,7 @@ const adminPages: Record<string, string> = {
'agent.col.credit': 'Limit / Used / Available',
'agent.col.direct_players': 'Direct players',
'agent.direct_players_title': 'Direct players · {name}',
'agent.open_agent_hint': 'Click an agent row to open their direct players page.',
'agent.platform_row_name': 'Platform',
'agent.col.sub_agents': 'Sub-agents',
'agent.col.cashback': 'Cashback rate',
@@ -201,6 +206,8 @@ const adminPages: Record<string, string> = {
'agent.hierarchy.max_level': 'Max agent level',
'agent.hierarchy.default_sub_credit_ratio': 'Default sub-agent credit ratio',
'agent.hierarchy.default_sub_credit_ratio_hint': 'When creating a sub-agent, pre-fill credit as parent available × this ratio',
'agent.suspend.settings_title': 'Default agent suspend behavior',
'agent.suspend.settings_hint': 'Each suspend/unfreeze action can override these; used as dialog defaults',
'agent.hierarchy.create_credit_default_hint': 'Default {ratio}% ({amount}), capped by parent available credit; adjustable',
'agent.hierarchy.create_credit_quick_hint': 'Parent available {amount} — click a ratio to fill',
'agent.hierarchy.create_level_hint': 'Will be created as level {n} agent',
@@ -235,6 +242,9 @@ const adminPages: Record<string, string> = {
'finance.remark.admin_deposit': 'Admin deposit',
'finance.remark.admin_withdraw': 'Admin withdraw',
'finance.remark.initial_balance': 'Initial account balance',
'finance.remark.revoke_deposit': 'Revoked approved deposit {orderNo}',
'finance.remark.deposit_order': 'Deposit order {orderNo}',
'finance.remark.cashback_batch': 'Cashback batch {batchNo}',
'agent.col.no_records': 'No records',
'agent.btn.confirm_adjust': 'Confirm',
'agent.field.select_user': 'Select user',
@@ -293,6 +303,8 @@ const adminPages: Record<string, string> = {
'match.hint.create_league': 'New tournaments are unpublished by default; use Publish in the list for player visibility, then expand to add fixtures.',
'league.status.PUBLISHED': 'Published',
'league.status.UNPUBLISHED': 'Unpublished',
'league.status.OUTRIGHT_SETTLED': 'Tournament settled',
'league.hint.outright_settled_no_fixture': 'Outright market is settled; new fixtures cannot be added.',
'league.btn.unpublish': 'Unpublish',
'league.confirm_unpublish': 'Players will no longer see this tournament; you can still edit and republish in admin. Continue?',
'msg.league_published': 'Tournament published',
@@ -303,6 +315,10 @@ const adminPages: Record<string, string> = {
'match.hint.edit_published': 'Published: edit kickoff, featured, display names; closed/settled are locked.',
'match.expand_league_hint': 'Expand a league to manage fixtures; use Outright odds for winner markets.',
'match.expand_outright_hint': 'Expand a league to edit winner odds; fixture teams sync automatically, and you can add teams not yet on the schedule.',
'match.open_league_hint': 'Click a league row to open its fixture management page.',
'match.open_outright_hint': 'Click a league row to open its outright odds page.',
'match.league_fixtures_subtitle': 'Fixtures',
'match.league_outrights_subtitle': 'Outright odds',
'outright.odds_only_hint': 'Teams from fixtures are added automatically; add extra teams manually and edit winner odds here. Outright follows league publish—no separate publish step.',
'outright.league_unpublished_hint': 'League is not published yet. Set the league to Published on this page to open outright betting automatically.',
'outright.unsettled_fixtures_hint': '{n} fixture(s) in this league are still unsettled. Settle them before settling the outright market.',
@@ -353,10 +369,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',
@@ -364,17 +384,62 @@ const adminPages: Record<string, string> = {
'audit.action.UPDATE_AGENT': 'Update agent',
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': 'Update player account settings',
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': 'Update agent suspend settings',
'audit.action.UPDATE_AGENT_HIERARCHY_SETTINGS': 'Update agent hierarchy settings',
'audit.action.UPDATE_PLATFORM_DIRECT_CASHBACK_SETTINGS': 'Update platform cashback settings',
'audit.action.UPDATE_BETTING_LIMITS': 'Update betting limits',
'audit.action.RESET_PLAYER_PASSWORD': 'Reset player password',
'audit.action.CONFIRM_SETTLEMENT': 'Confirm settlement',
'audit.action.CONFIRM_RESETTLE': 'Confirm resettlement',
'audit.action.CONFIRM_CASHBACK': 'Confirm cashback payout',
'audit.action.CANCEL_CASHBACK': 'Cancel cashback batch',
'audit.action.CREATE_STAFF': 'Create staff account',
'audit.action.UPDATE_STAFF': 'Edit staff account',
'audit.action.DELETE_STAFF': 'Delete staff account',
'audit.action.DELETE_PLAYER': 'Delete player',
'audit.action.PURGE_UNUSED_FILES': 'Purge unused media',
'audit.action.FORGOT_PASSWORD_RESET': 'Player forgot-password reset',
'audit.action.CREATE_LEAGUE': 'Create league',
'audit.action.UPDATE_LEAGUE': 'Update league',
'audit.action.ARCHIVE_LEAGUE': 'Archive league',
'audit.action.CREATE_TEAM': 'Create team',
'audit.action.CREATE_MATCH': 'Create match',
'audit.action.UPDATE_MATCH': 'Update match',
'audit.action.DELETE_MATCH': 'Delete match',
'audit.action.ARCHIVE_MATCH': 'Archive match',
'audit.action.IMPORT_MATCHES': 'Import matches',
'audit.action.PUBLISH_MATCH': 'Publish match',
'audit.action.UNPUBLISH_MATCH': 'Unpublish match',
'audit.action.CLOSE_MATCH': 'Close match',
'audit.action.REOPEN_MATCH': 'Reopen match',
'audit.action.CANCEL_MATCH': 'Cancel match',
'audit.action.CREATE_MARKET_TEMPLATE': 'Create market template',
'audit.action.UPDATE_MARKET_TEMPLATE': 'Update market template',
'audit.action.DUPLICATE_MARKET_TEMPLATE': 'Duplicate market template',
'audit.action.SET_DEFAULT_MARKET_TEMPLATE': 'Set default market template',
'audit.action.GENERATE_MATCH_MARKETS': 'Generate match markets',
'audit.action.APPLY_MARKET_TEMPLATE': 'Apply market template',
'audit.action.BULK_SAVE_MATCH_MARKETS': 'Bulk save match markets',
'audit.action.UPDATE_MATCH_ODDS': 'Update match odds',
'audit.action.UPDATE_MARKET': 'Update market',
'audit.action.UPDATE_SELECTION': 'Update selection',
'audit.action.CREATE_OUTRIGHT': 'Create outright',
'audit.action.UPDATE_OUTRIGHT': 'Update outright',
'audit.action.UPDATE_OUTRIGHT_ODDS': 'Update outright odds',
'audit.action.ADD_OUTRIGHT_SELECTION': 'Add outright selection',
'audit.action.BATCH_ADD_OUTRIGHT_SELECTIONS': 'Batch add outright selections',
'audit.action.UPDATE_OUTRIGHT_SELECTION': 'Update outright selection',
'audit.action.REMOVE_OUTRIGHT_SELECTION': 'Remove outright selection',
'audit.action.IMPORT_WC2026_OUTRIGHT': 'Import WC2026 outright',
'audit.module.CATALOG': 'Match catalog',
'audit.module.USERS': 'Players',
'audit.module.AGENTS': 'Agents',
'audit.module.SYSTEM': 'System',
'audit.module.SETTINGS': 'Settings',
'audit.module.SETTLEMENT': 'Settlement',
'audit.module.CASHBACK': 'Cashback',
'audit.module.STAFF': 'Staff',
'audit.module.MEDIA': 'Media library',
'audit.module.identity': 'Identity',
'cashback.start_date': 'Start date',
'cashback.end_date': 'End date',
@@ -583,6 +648,7 @@ const adminPages: Record<string, string> = {
'settlement.record_score': 'Save score',
'settlement.preview_hint': 'Preview moves the match to pending settlement and calculates payouts (scores are saved on confirm; you can reopen betting before confirming)',
'settlement.preview_btn': 'Preview settlement',
'settlement.view_preview_btn': 'View settlement preview',
'settlement.preview_failed': 'Failed to generate settlement preview',
'settlement.err_score_not_recorded': 'Enter half-time and full-time scores before preview',
'settlement.must_close_first': 'Close betting before settlement',
@@ -621,6 +687,26 @@ const adminPages: Record<string, string> = {
'settlement.resettle_topup': 'Top-up required',
'settlement.resettle_clawback': 'Clawback required',
'settlement.resettle_confirm': 'Confirm resettle',
'settlement.resettle_affected_list': 'Affected Bets Details',
'settlement.resettle_col.old_result': 'Old Result/Old Payout',
'settlement.resettle_col.new_result': 'New Result/New Payout',
'settlement.resettle_col.adjust': 'Adjustment',
'settlement.history_tab': 'Settlement Records',
'settlement.history_tab_bets': 'Bet Stats',
'settlement.history.no_records': 'No settlement records',
'settlement.history.col.batch_no': 'Batch No.',
'settlement.history.col.type': 'Type',
'settlement.history.col.score': 'Score (HT/FT)',
'settlement.history.col.corners': 'Corners (H/A)',
'settlement.history.col.cards': 'Yellow/Red cards (H/A)',
'settlement.history.col.total_bets': 'Settled bets',
'settlement.history.col.total_payout': 'Total payout',
'settlement.history.col.total_refund': 'Total refund',
'settlement.history.col.operator': 'Operator',
'settlement.history.col.time': 'Settled at',
'settlement.history.col.reason': 'Reason',
'settlement.history.type.initial': 'Initial settlement',
'settlement.history.type.resettle': 'Resettlement',
'user.betting_limits': 'Betting limits',
'user.betting_limits_hint': 'Global stake/payout/daily limits for player bets',
'user.limit.min_stake': 'Min stake',
@@ -786,15 +872,70 @@ 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.upload.load_media_failed': 'Failed to load media library',
'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.banner': 'Homepage promo',
'content.inbox_notify.banner_hint': 'Auto-broadcast when publishing a banner with inbox notify checked',
'content.inbox_notify.announcement': 'Announcements / ticker',
'content.inbox_notify.announcement_hint': 'Auto-broadcast when publishing notice/ticker with inbox notify checked',
'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.inbox_broadcast.title': 'Manual send',
'content.inbox_broadcast.hint': 'Manually send inbox messages to players. Deleting a record also removes the message from player inboxes.',
'content.inbox_broadcast.field_title': 'Title',
'content.inbox_broadcast.field_body': 'Body',
'content.inbox_broadcast.field_target': 'Recipients',
'content.inbox_broadcast.field_username': 'Player username',
'content.inbox_broadcast.target_all': 'All players',
'content.inbox_broadcast.target_user': 'Single player',
'content.inbox_broadcast.username_placeholder': 'Enter player login username',
'content.inbox_broadcast.send': 'Send',
'content.inbox_broadcast.send_success': 'Sent to {n} player(s)',
'content.inbox_broadcast.form_invalid': 'Provide a title or body in at least one language',
'content.inbox_broadcast.target_user_required': 'Player username is required',
'content.inbox_broadcast.history_title': 'Send history',
'content.inbox_broadcast.view_title': 'Send details',
'content.inbox_broadcast.col_title': 'Title',
'content.inbox_broadcast.col_target': 'Target',
'content.inbox_broadcast.col_recipients': 'Count',
'content.inbox_broadcast.col_sender': 'Sender',
'content.inbox_broadcast.col_time': 'Sent at',
'content.inbox_broadcast.delete_confirm': 'Delete "{title}"? Player inbox copies will be removed too.',
'content.inbox_broadcast.locale_fallback_hint': 'Missing locales fall back in order: player language → English → Simplified Chinese → Malay.',
'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 +949,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 +964,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',
@@ -1014,6 +1156,15 @@ const adminPages: Record<string, string> = {
'media.no_files': 'No files yet',
'media.refresh': 'Refresh',
'media.unused_count': '{n} unused',
'media.storage_stats': 'Storage Statistics',
'media.deposits_on_disk': 'Recharge Screenshots (Unmapped to Media Library)',
'media.screenshot_cleanup': 'Screenshot Cleanup',
'media.cleanup_auto_enabled': 'Enable Auto-cleanup',
'media.cleanup_keep_days': 'Retention Days',
'media.cleanup_before_date': 'Manual Cleanup Before Date',
'media.cleanup_run_now': 'Clean Now',
'media.cleanup_result': 'Successfully cleaned {cleaned} screenshot(s), freeing {size} of space.',
'media.cleanup_expired_tag': 'Purged',
};
export default adminPages;

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': '玩家返水率',
@@ -137,6 +141,7 @@ const adminPages: Record<string, string> = {
'agent.col.credit': '授信/已用/可用',
'agent.col.direct_players': '直属玩家',
'agent.direct_players_title': '直属玩家 · {name}',
'agent.open_agent_hint': '点击代理行进入直属玩家管理页。',
'agent.platform_row_name': '平台',
'agent.col.sub_agents': '下级代理',
'agent.col.cashback': '返水率',
@@ -202,6 +207,8 @@ const adminPages: Record<string, string> = {
'agent.hierarchy.max_level': '最大代理层级',
'agent.hierarchy.default_sub_credit_ratio': '下级默认授信比例',
'agent.hierarchy.default_sub_credit_ratio_hint': '创建下级代理时,授信额度默认预填为上级可用授信 × 此比例',
'agent.suspend.settings_title': '停用代理默认行为',
'agent.suspend.settings_hint': '单次停用/解冻操作仍可单独勾选覆盖;此处为对话框默认勾选状态',
'agent.hierarchy.create_credit_default_hint': '默认 {ratio}%{amount}),不超过上级可用授信,可手动调整',
'agent.hierarchy.create_credit_quick_hint': '上级可用授信 {amount},点击比例快速填入',
'agent.hierarchy.create_level_hint': '将创建为 {n} 级代理',
@@ -236,6 +243,9 @@ const adminPages: Record<string, string> = {
'finance.remark.admin_deposit': '管理员上分',
'finance.remark.admin_withdraw': '管理员下分',
'finance.remark.initial_balance': '开户初始余额',
'finance.remark.revoke_deposit': '撤销已通过充值 {orderNo}',
'finance.remark.deposit_order': '充值订单 {orderNo}',
'finance.remark.cashback_batch': '返水批次 {batchNo}',
'agent.col.no_records': '暂无记录',
'agent.btn.confirm_adjust': '确认调整',
'agent.field.select_user': '选择用户',
@@ -294,6 +304,8 @@ const adminPages: Record<string, string> = {
'match.hint.create_league': '新建联赛默认为未发布,请在列表点击「发布」后玩家端可见;展开该行可添加单场。',
'league.status.PUBLISHED': '已发布',
'league.status.UNPUBLISHED': '未发布',
'league.status.OUTRIGHT_SETTLED': '赛事已结算',
'league.hint.outright_settled_no_fixture': '优胜赛已结算,不可再新增单场。',
'league.btn.unpublish': '下架',
'league.confirm_unpublish': '下架后玩家端将不再展示该联赛,管理端仍可编辑与重新发布。是否继续?',
'msg.league_published': '联赛已发布',
@@ -304,6 +316,10 @@ const adminPages: Record<string, string> = {
'match.hint.edit_published': '已发布:可修改开赛时间、热门及显示名称;封盘/已结算后不可编辑。',
'match.expand_league_hint': '展开联赛可管理单场赛事;优胜冠军盘口请到「优胜赛配置」。',
'match.expand_outright_hint': '展开联赛可编辑夺冠赔率;单场球队会自动同步,也可手动补充尚未赛程的球队。',
'match.open_league_hint': '点击联赛行进入单场赛事管理页。',
'match.open_outright_hint': '点击联赛行进入优胜冠军赔率配置页。',
'match.league_fixtures_subtitle': '单场赛事',
'match.league_outrights_subtitle': '优胜冠军赔率',
'outright.odds_only_hint': '单场赛程中的球队会自动加入;可手动添加尚未参赛的球队,并在此调整赔率。冠军盘随联赛发布,无需单独发布。',
'outright.league_unpublished_hint': '联赛尚未发布,请在本页编辑联赛并设为「已发布」后,冠军盘将自动开放投注。',
'outright.unsettled_fixtures_hint': '该联赛仍有 {n} 场单场未结算,请先完成单场结算后再结算冠军盘。',
@@ -354,10 +370,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': '重置数据库',
@@ -365,17 +385,62 @@ const adminPages: Record<string, string> = {
'audit.action.UPDATE_AGENT': '更新代理',
'audit.action.UPDATE_PLAYER_ACCOUNT_SETTINGS': '更新玩家账号设置',
'audit.action.UPDATE_AGENT_SUSPEND_SETTINGS': '更新代理停押设置',
'audit.action.UPDATE_AGENT_HIERARCHY_SETTINGS': '更新代理层级设置',
'audit.action.UPDATE_PLATFORM_DIRECT_CASHBACK_SETTINGS': '更新平台返水设置',
'audit.action.UPDATE_BETTING_LIMITS': '更新投注限额',
'audit.action.RESET_PLAYER_PASSWORD': '重置玩家密码',
'audit.action.CONFIRM_SETTLEMENT': '确认结算',
'audit.action.CONFIRM_RESETTLE': '确认重结算',
'audit.action.CONFIRM_CASHBACK': '确认发放返水',
'audit.action.CANCEL_CASHBACK': '作废返水批次',
'audit.action.CREATE_STAFF': '创建后台账号',
'audit.action.UPDATE_STAFF': '编辑后台账号',
'audit.action.DELETE_STAFF': '删除后台账号',
'audit.action.DELETE_PLAYER': '删除玩家',
'audit.action.PURGE_UNUSED_FILES': '清理未引用媒体',
'audit.action.FORGOT_PASSWORD_RESET': '玩家找回密码',
'audit.action.CREATE_LEAGUE': '新建联赛',
'audit.action.UPDATE_LEAGUE': '更新联赛',
'audit.action.ARCHIVE_LEAGUE': '归档联赛',
'audit.action.CREATE_TEAM': '新建球队',
'audit.action.CREATE_MATCH': '新建赛事',
'audit.action.UPDATE_MATCH': '更新赛事',
'audit.action.DELETE_MATCH': '删除赛事',
'audit.action.ARCHIVE_MATCH': '归档赛事',
'audit.action.IMPORT_MATCHES': '批量导入赛事',
'audit.action.PUBLISH_MATCH': '发布赛事',
'audit.action.UNPUBLISH_MATCH': '下架赛事',
'audit.action.CLOSE_MATCH': '封盘赛事',
'audit.action.REOPEN_MATCH': '重新开放赛事',
'audit.action.CANCEL_MATCH': '取消赛事',
'audit.action.CREATE_MARKET_TEMPLATE': '新建盘口模板',
'audit.action.UPDATE_MARKET_TEMPLATE': '更新盘口模板',
'audit.action.DUPLICATE_MARKET_TEMPLATE': '复制盘口模板',
'audit.action.SET_DEFAULT_MARKET_TEMPLATE': '设为默认盘口模板',
'audit.action.GENERATE_MATCH_MARKETS': '生成赛事盘口',
'audit.action.APPLY_MARKET_TEMPLATE': '应用盘口模板',
'audit.action.BULK_SAVE_MATCH_MARKETS': '批量保存赛事盘口',
'audit.action.UPDATE_MATCH_ODDS': '更新赛事赔率',
'audit.action.UPDATE_MARKET': '更新盘口',
'audit.action.UPDATE_SELECTION': '更新选项',
'audit.action.CREATE_OUTRIGHT': '新建冠军盘',
'audit.action.UPDATE_OUTRIGHT': '更新冠军盘',
'audit.action.UPDATE_OUTRIGHT_ODDS': '更新冠军盘赔率',
'audit.action.ADD_OUTRIGHT_SELECTION': '添加冠军盘选项',
'audit.action.BATCH_ADD_OUTRIGHT_SELECTIONS': '批量添加冠军盘选项',
'audit.action.UPDATE_OUTRIGHT_SELECTION': '更新冠军盘选项',
'audit.action.REMOVE_OUTRIGHT_SELECTION': '移除冠军盘选项',
'audit.action.IMPORT_WC2026_OUTRIGHT': '导入世界杯冠军盘',
'audit.module.CATALOG': '赛事管理',
'audit.module.USERS': '玩家',
'audit.module.AGENTS': '代理',
'audit.module.SYSTEM': '系统',
'audit.module.SETTINGS': '系统设置',
'audit.module.SETTLEMENT': '结算',
'audit.module.CASHBACK': '返水',
'audit.module.STAFF': '后台账号',
'audit.module.MEDIA': '媒体库',
'audit.module.identity': '身份认证',
'cashback.start_date': '开始日期',
'cashback.end_date': '结束日期',
@@ -565,7 +630,7 @@ const adminPages: Record<string, string> = {
'settlement.chart.stake_by_selection': '选项单关投注额 TOP6',
'settlement.stats_by_market': '按玩法 / 选项汇总',
'settlement.bet_list': '相关注单',
'settlement.bet_list_hint': '按注单聚合;同场串关含多腿时显示 ×腿数',
'settlement.bet_list_hint': '按注单聚合;同场串关含多腿时显示 ×腿数(点击标签可切换查看结算记录)',
'settlement.no_bets': '本场暂无注单',
'settlement.col.market': '玩法',
'settlement.col.selection': '选项',
@@ -584,6 +649,7 @@ const adminPages: Record<string, string> = {
'settlement.record_score': '录入比分',
'settlement.preview_hint': '填写比分后点击生成预览,赛事将进入待结算并计算派彩(正式比分在确认结算后保存;未确认前仍可解除封盘)',
'settlement.preview_btn': '生成结算预览',
'settlement.view_preview_btn': '查看结算预览',
'settlement.preview_failed': '生成结算预览失败',
'settlement.err_score_not_recorded': '请先填写半场与全场比分后再生成预览',
'settlement.must_close_first': '请先封盘后再结算',
@@ -621,6 +687,26 @@ const adminPages: Record<string, string> = {
'settlement.resettle_topup': '需补发金额',
'settlement.resettle_clawback': '需扣回金额',
'settlement.resettle_confirm': '确认重结算',
'settlement.resettle_affected_list': '受影响注单明细',
'settlement.resettle_col.old_result': '原结果/原派彩',
'settlement.resettle_col.new_result': '新结果/新派彩',
'settlement.resettle_col.adjust': '差额调整',
'settlement.history_tab': '结算记录',
'settlement.history_tab_bets': '注单统计',
'settlement.history.no_records': '暂无结算记录',
'settlement.history.col.batch_no': '批次号',
'settlement.history.col.type': '类型',
'settlement.history.col.score': '比分(半/全)',
'settlement.history.col.corners': '角球(主/客)',
'settlement.history.col.cards': '黄牌/红牌(主/客)',
'settlement.history.col.total_bets': '结算注单数',
'settlement.history.col.total_payout': '总派彩',
'settlement.history.col.total_refund': '总退款',
'settlement.history.col.operator': '操作人',
'settlement.history.col.time': '结算时间',
'settlement.history.col.reason': '原因',
'settlement.history.type.initial': '首次结算',
'settlement.history.type.resettle': '重新结算',
'user.betting_limits': '投注限额',
'user.betting_limits_hint': '全局下注校验:最小/最大投注、最高派彩、每日投注上限',
'user.limit.min_stake': '最小投注',
@@ -790,15 +876,70 @@ 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.banner': '首页推广',
'content.inbox_notify.banner_hint': '发布 Banner 且勾选邮箱通知时自动群发',
'content.inbox_notify.announcement': '公告 / 跑马灯',
'content.inbox_notify.announcement_hint': '发布通知或跑马灯且勾选邮箱通知时自动群发',
'content.inbox_notify.manual_title': '以下类型在新建时勾选「邮箱通知」',
'content.inbox_notify.banner_note': '首页推广',
'content.inbox_notify.announcement_note': '通知公告 / 跑马灯',
'content.inbox_broadcast.title': '手动发送',
'content.inbox_broadcast.hint': '手动向玩家发送站内信;删除记录将同时撤回玩家端对应消息。',
'content.inbox_broadcast.field_title': '标题',
'content.inbox_broadcast.field_body': '正文',
'content.inbox_broadcast.field_target': '发送对象',
'content.inbox_broadcast.field_username': '玩家账号',
'content.inbox_broadcast.target_all': '全部玩家',
'content.inbox_broadcast.target_user': '指定玩家',
'content.inbox_broadcast.username_placeholder': '输入玩家登录账号',
'content.inbox_broadcast.send': '发送',
'content.inbox_broadcast.send_success': '已发送给 {n} 位玩家',
'content.inbox_broadcast.form_invalid': '请至少填写一种语言的标题或正文',
'content.inbox_broadcast.target_user_required': '请填写玩家账号',
'content.inbox_broadcast.history_title': '发送记录',
'content.inbox_broadcast.view_title': '发送详情',
'content.inbox_broadcast.col_title': '标题',
'content.inbox_broadcast.col_target': '对象',
'content.inbox_broadcast.col_recipients': '人数',
'content.inbox_broadcast.col_sender': '发送人',
'content.inbox_broadcast.col_time': '发送时间',
'content.inbox_broadcast.delete_confirm': '确定删除「{title}」?玩家端对应消息将一并删除。',
'content.inbox_broadcast.locale_fallback_hint': '未填写的语言将按玩家语言 → 英文 → 简体中文 → 马来语顺序回退使用已有内容。',
'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.upload.load_media_failed': '加载媒体库失败',
'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 +949,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 +964,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': '无跳转',
@@ -1021,6 +1163,15 @@ const adminPages: Record<string, string> = {
'media.no_files': '暂无文件',
'media.refresh': '刷新',
'media.unused_count': '{n} 个未使用',
'media.storage_stats': '存储空间统计',
'media.deposits_on_disk': '充值订单截图 (未挂靠媒体库)',
'media.screenshot_cleanup': '充值截图清理',
'media.cleanup_auto_enabled': '开启自动定期清理',
'media.cleanup_keep_days': '截图保留天数',
'media.cleanup_before_date': '手动清理截止日期',
'media.cleanup_run_now': '立即清理',
'media.cleanup_result': '成功清理了 {cleaned} 张截图,释放了 {size} 空间。',
'media.cleanup_expired_tag': '已清理',
};
export default adminPages;

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', matchPrefix: true, 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);
});
@@ -81,19 +117,21 @@ function isDepositSectionPath(path: string) {
return path === '/deposit' || path === '/deposit-orders' || path === '/payment-methods';
}
function isMenuActive(menu: { path: string; matchPrefix?: boolean }, path: string) {
if (path === menu.path) return true;
if (!menu.matchPrefix) return false;
if (menu.path === '/') return isDashboardSectionPath(path);
if (menu.path === '/deposit') return isDepositSectionPath(path);
if (menu.path === '/matches') return isMatchesSectionPath(path);
return path.startsWith(`${menu.path}/`);
}
const currentLabel = computed(() => {
const hit = menus.value.find((m) => {
if ('matchPrefix' in m && m.matchPrefix) {
if (m.path === '/') return isDashboardSectionPath(route.path);
if (m.path === '/deposit') return isDepositSectionPath(route.path);
return isMatchesSectionPath(route.path);
}
return route.path === m.path;
});
const hit = menus.value.find((m) => isMenuActive(m, route.path));
return hit?.label ?? '';
});
const topbarCrumbs = computed(() => resolveAdminBreadcrumb(route.path, t));
const topbarCrumbs = computed(() => resolveAdminBreadcrumb(route.path, t, route.query));
const roleLabel = computed(() => {
if (auth.isAdmin.value) {
@@ -139,6 +177,10 @@ function onNavClick() {
}
}
function prefetchNavRoute(path: string) {
prefetchRouteChunks(router, path);
}
function logout() {
auth.logout();
router.push('/login');
@@ -149,11 +191,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,27 +221,29 @@ 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">
<RouterLink
v-for="m in menus" :key="m.path" :to="m.path"
class="nav-item"
:class="{
active:
route.path === m.path ||
('matchPrefix' in m &&
m.matchPrefix &&
(m.path === '/'
? isDashboardSectionPath(route.path)
: m.path === '/deposit'
? isDepositSectionPath(route.path)
: isMatchesSectionPath(route.path))),
}"
:class="{ active: isMenuActive(m, 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 +291,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, route: layoutRoute }">
<KeepAlive :max="10" :include="keepAliveIncludes">
<component :is="Component" :key="layoutRoute.matched[1]?.path ?? layoutRoute.path" />
</KeepAlive>
</RouterView>
</main>
</div>
</div>
@@ -285,17 +334,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 +382,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 +660,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';
@@ -54,6 +55,18 @@ const router = createRouter({
path: 'users',
component: () => import('../views/AgentManager.vue'),
meta: { adminOnly: true, permissions: [AdminPerm.usersView, AdminPerm.agentsView] },
children: [
{
path: 'agents/:agentId/players',
name: 'admin-agent-direct-players',
component: () => import('../views/agent/AgentDirectPlayersView.vue'),
},
{
path: 'settings',
name: 'admin-global-settings',
component: () => import('../views/agent/GlobalSettingsView.vue'),
},
],
},
{
path: 'finance-logs',
@@ -75,11 +88,25 @@ const router = createRouter({
path: 'matches',
component: () => import('../views/Matches.vue'),
meta: { adminOnly: true, permissions: [AdminPerm.matches] },
children: [
{
path: 'leagues/:leagueId',
name: 'admin-league-matches',
component: () => import('../views/matches/LeagueMatchesPage.vue'),
},
],
},
{
path: 'matches/outrights',
component: () => import('../views/MatchesOutrights.vue'),
meta: { adminOnly: true, permissions: [AdminPerm.matches] },
children: [
{
path: 'leagues/:leagueId',
name: 'admin-league-outrights',
component: () => import('../views/matches/LeagueOutrightsPage.vue'),
},
],
},
{
path: 'matches/market-templates',
@@ -191,7 +218,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 +269,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

@@ -7,10 +7,39 @@ export interface AdminBreadcrumbItem {
export function resolveAdminBreadcrumb(
path: string,
t: (key: string) => string,
query: Record<string, unknown> = {},
): AdminBreadcrumbItem[] | null {
if (/^\/settlement\/[^/]+/.test(path)) {
if (/^\/users\/agents\/[^/]+\/players/.test(path)) {
return [
{ label: t('nav.agents_players'), to: '/users' },
{ label: t('breadcrumb.agent_direct_players') },
];
}
if (path === '/users/settings') {
return [
{ label: t('nav.agents_players'), to: '/users' },
{ label: t('user.page_settings') },
];
}
if (/^\/matches\/leagues\/[^/]+/.test(path)) {
return [
{ label: t('nav.matches'), to: '/matches' },
{ label: t('breadcrumb.league_fixtures') },
];
}
if (/^\/matches\/outrights\/leagues\/[^/]+/.test(path)) {
return [
{ label: t('nav.matches'), to: '/matches/outrights' },
{ label: t('breadcrumb.league_outrights') },
];
}
if (/^\/settlement\/[^/]+/.test(path)) {
const returnTo =
typeof query.returnTo === 'string' && query.returnTo.startsWith('/')
? query.returnTo
: '/matches';
return [
{ label: t('nav.matches'), to: returnTo },
{ label: t('breadcrumb.settlement') },
];
}

View File

@@ -0,0 +1,13 @@
const STALE_KEY = 'admin:list-stale';
/** 标记赛事相关列表需在下次激活时刷新(结算/预览等变更后端统计后调用) */
export function markAdminListStale() {
sessionStorage.setItem(STALE_KEY, '1');
}
/** 若曾标记过 stale 则清除标记并返回 true */
export function consumeAdminListStale(): boolean {
if (sessionStorage.getItem(STALE_KEY) !== '1') return false;
sessionStorage.removeItem(STALE_KEY);
return true;
}

View File

@@ -0,0 +1,39 @@
import { getAdminLocale } from '../i18n';
import type { AdminLocale } from '../i18n/admin-messages';
function resolveLocale(locale?: AdminLocale): AdminLocale {
return locale ?? getAdminLocale();
}
/** 列表展示:年月日 */
export function formatAdminDateTimeBrief(
value: string | null | undefined,
locale?: AdminLocale,
): string {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString(resolveLocale(locale), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
/** 悬停详情:含秒 */
export function formatAdminDateTimeFull(
value: string | null | undefined,
locale?: AdminLocale,
): string {
if (!value) return '—';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleString(resolveLocale(locale), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}

View File

@@ -0,0 +1,71 @@
/** 去除 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);
}
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;
}

View File

@@ -1,10 +1,8 @@
/** 赛事列表 UI 状态(返回列表时恢复展开等 */
/** 赛事列表 UI 状态(返回列表时恢复筛选与分页 */
const STORAGE_KEY = 'admin_matches_list_ui';
export const MAX_EXPANDED_LEAGUES = 3;
export type MatchesListUiState = {
expandedLeagueIds: string[];
page: number;
pageSize: number;
filterStatus: string;
@@ -13,7 +11,6 @@ export type MatchesListUiState = {
function defaultState(): MatchesListUiState {
return {
expandedLeagueIds: [],
page: 1,
pageSize: 10,
filterStatus: '',
@@ -21,44 +18,21 @@ function defaultState(): MatchesListUiState {
};
}
function capExpanded(ids: string[]): string[] {
return ids.slice(0, MAX_EXPANDED_LEAGUES);
}
export function readMatchesListUiState(): MatchesListUiState | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as MatchesListUiState;
if (!Array.isArray(parsed.expandedLeagueIds)) return null;
return {
...parsed,
expandedLeagueIds: capExpanded(parsed.expandedLeagueIds),
};
return JSON.parse(raw) as MatchesListUiState;
} catch {
return null;
}
}
export function writeMatchesListUiState(state: MatchesListUiState) {
sessionStorage.setItem(
STORAGE_KEY,
JSON.stringify({
...state,
expandedLeagueIds: capExpanded(state.expandedLeagueIds),
}),
);
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}
export function patchMatchesListUiState(patch: Partial<MatchesListUiState>) {
const base = readMatchesListUiState() ?? defaultState();
writeMatchesListUiState({ ...base, ...patch });
}
/** 从子页返回前确保该赛事行处于展开记录中 */
export function ensureLeagueExpanded(leagueId: string) {
if (!leagueId) return;
const base = readMatchesListUiState() ?? defaultState();
const ids = capExpanded([...new Set([...base.expandedLeagueIds, leagueId])]);
writeMatchesListUiState({ ...base, expandedLeagueIds: ids });
}

View File

@@ -0,0 +1,25 @@
import api from '../api';
export type MediaLibraryItem = {
id: string;
filename: string;
url: string;
mimeType: string;
category?: string;
};
/** 媒体库图片(默认全部分类,供「从媒体库选择」使用) */
export async function fetchMediaLibraryImages(opts?: {
category?: string;
pageSize?: number;
}): Promise<MediaLibraryItem[]> {
const params: Record<string, string | number> = {
pageSize: opts?.pageSize ?? 200,
imagesOnly: '1',
};
if (opts?.category?.trim()) {
params.category = opts.category.trim();
}
const { data } = await api.get('/admin/files', { params });
return (data.data?.items ?? []) as MediaLibraryItem[];
}

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);
}
@@ -63,6 +74,7 @@ export async function hydrateStaffSession(): Promise<boolean> {
maxAgentLevel: typeof raw.maxAgentLevel === 'number' ? raw.maxAgentLevel : null,
canManageSubAgents: raw.canManageSubAgents === true,
inviteCode: raw.inviteCode ?? null,
visibleMenus: raw.visibleMenus ?? null,
});
lastHydrateAt = Date.now();
return true;

View File

@@ -1,3 +1,5 @@
export { txDisplayAmount } from '@thebet365/shared';
export const TX_KEY_MAP: Record<string, string> = {
MANUAL_DEPOSIT: 'finance.tx.deposit',
ADMIN_DEPOSIT: 'finance.tx.admin_deposit',
@@ -64,3 +66,45 @@ export function walletDepositMethodLabel(
if (type === 'PLAYER_DEPOSIT') return t('finance.tx.player_deposit');
return '—';
}
const WALLET_REMARK_EXACT: Record<string, string> = {
'Agent deposit': 'finance.remark.agent_deposit',
'Agent withdraw': 'finance.remark.agent_withdraw',
'代理上分': 'finance.remark.agent_deposit',
'代理下分': 'finance.remark.agent_withdraw',
'管理员上分': 'finance.remark.admin_deposit',
'管理员下分': 'finance.remark.admin_withdraw',
'开户初始余额': 'finance.remark.initial_balance',
'Resettlement adjustment': 'finance.tx.resettle',
};
/** 钱包流水备注:系统英文/中文模板按当前语言展示 */
export function walletRemarkLabel(
remark: string | null | undefined,
transactionType: string,
t: (key: string, params?: Record<string, string | number>) => string,
): string {
const raw = remark?.trim();
if (!raw) {
if (transactionType === 'MANUAL_DEPOSIT') return t('finance.remark.agent_deposit');
if (transactionType === 'MANUAL_WITHDRAW') return t('finance.remark.agent_withdraw');
return '—';
}
const exactKey = WALLET_REMARK_EXACT[raw];
if (exactKey) return t(exactKey);
const revokeEn = raw.match(/^Revoke approved deposit\s+([A-Z0-9]+)$/i);
if (revokeEn) return t('finance.remark.revoke_deposit', { orderNo: revokeEn[1] });
const revokeZh = raw.match(/^撤销已通过充值\s+([A-Z0-9]+)$/);
if (revokeZh) return t('finance.remark.revoke_deposit', { orderNo: revokeZh[1] });
const depositOrder = raw.match(/^Deposit order\s+([A-Z0-9]+)$/i);
if (depositOrder) return t('finance.remark.deposit_order', { orderNo: depositOrder[1] });
const cashbackBatch = raw.match(/^Cashback batch\s+(.+)$/i);
if (cashbackBatch) return t('finance.remark.cashback_batch', { batchNo: cashbackBatch[1].trim() });
return raw;
}

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,16 +1,17 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch, reactive, h } from 'vue';
import { useRouter } from 'vue-router';
import { ref, onMounted, onActivated, computed, watch, reactive, h, provide } from 'vue';
defineOptions({ name: 'AdminAgentManager' });
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError, resolveApiError } from '../i18n/form-validation';
import api from '../api';
import { clearStaffSession } from '../stores/auth';
import { usePermissions } from '../composables/usePermissions';
import { AdminPerm } from '../constants/permissions';
const { t, localeTag } = useAdminLocale();
const route = useRoute();
const router = useRouter();
const { hasPermission, role: staffRole } = usePermissions();
@@ -64,9 +65,9 @@ import {
} from '../utils/format-amount';
import { formatAgentLevelNumeral } from '../utils/agent-level-label';
import {
shouldToggleExpandOnRowClick,
expandableTableRowClassName,
} from '../utils/expandable-table';
agentDirectPlayersReloadKey,
agentPlayerActionsKey,
} from '../composables/agent-direct-players-context';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import PlayerWalletLedgerDialog from '../components/PlayerWalletLedgerDialog.vue';
import WalletTransferContext from '../components/WalletTransferContext.vue';
@@ -77,6 +78,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 +96,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('');
@@ -110,6 +112,11 @@ type SubAgentLevelState = {
const subAgentLevelState = reactive<Record<number, SubAgentLevelState>>({});
const agentLevelCounts = ref<Record<number, number>>({});
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 });
const agentSuspendDefaults = ref({
suspendFreezeDirectPlayers: false,
suspendBlockPlayerLogin: false,
});
function ensureSubAgentState(level: number): SubAgentLevelState {
if (!subAgentLevelState[level]) {
@@ -117,7 +124,7 @@ function ensureSubAgentState(level: number): SubAgentLevelState {
agents: [],
total: 0,
page: 1,
pageSize: 20,
pageSize: 10,
keyword: '',
filterStatus: '',
};
@@ -166,19 +173,19 @@ 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('');
const playerLoading = ref(false);
const agentOptions = ref<{ id: string; username: string; level: number; parentUsername?: string | null }[]>([]);
/* ─── Expansion state ─── */
const expandedSet = ref(new Set<string>());
const agentPlayersMap = ref<Record<string, PlayerRow[]>>({});
const expandLoading = ref<Record<string, boolean>>({});
const directPlayersReload = ref<(() => void) | null>(null);
provide(agentDirectPlayersReloadKey, directPlayersReload);
const expandedRowKeys = computed(() => Array.from(expandedSet.value));
const isAgentChildRoute = computed(() =>
/^\/users\/agents\/[^/]+\/players/.test(route.path) || route.path === '/users/settings',
);
const createToolbarChildLevel = ref<number | null>(null);
@@ -214,20 +221,12 @@ const creditForm = ref({ amount: 10000, remark: '' });
const creditContext = ref<AgentCreditAdjustContext | null>(null);
const creditContextLoading = ref(false);
/* ─── Global settings ─── */
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
const bettingLimits = ref({
minStake: 1,
maxStakeSingle: 50000,
maxStakeParlay: 20000,
maxPayoutSingle: 500000,
maxPayoutParlay: 1000000,
dailyStakeLimit: 200000,
});
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const hierarchySettings = ref({ maxAgentLevel: 0 });
const DEFAULT_SUB_AGENT_CREDIT_RATIO = 50;
/* ─── Init ─── */
let pageInitPromise: Promise<void> | null = null;
const pageInitLoaded = ref(false);
const DEFAULT_SUB_AGENT_CREDIT_RATIO = computed(
() => hierarchySettings.value.defaultSubAgentCreditRatio || 50,
);
const freezeAgentVisible = ref(false);
const freezeAgentLoading = ref(false);
const freezeAgentTarget = ref<AgentRow | null>(null);
@@ -236,18 +235,7 @@ const freezeAgentForm = ref({
blockDirectPlayerLogin: false,
unfreezeDirectPlayers: false,
});
const hierarchySaving = ref(false);
const platformDirectRate = ref(0);
const adminInviteRate = ref(0);
const platformDirectSaving = ref(false);
const resetAllowed = ref(false);
const resetLoading = ref(false);
const resetConfirmPhrase = ref('');
const settingsCollapseOpen = ref<string[]>([]);
const settingsLoaded = ref(false);
const resetDbStatusLoaded = ref(false);
const agentOptionsLoading = ref(false);
const MAX_EXPANDED_AGENT_ROWS = 2;
const createDialogTitle = computed(() => {
if (createAccountMode.value === 1) return t('agent.dialog.create');
@@ -341,7 +329,7 @@ function computeSubAgentCreditByRatio(available: number, ratioPercent: number):
const creditQuickRatios = [10, 15, 20, 30] as const;
function computeDefaultSubAgentCreditLimit(available: number): number {
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO);
return computeSubAgentCreditByRatio(available, DEFAULT_SUB_AGENT_CREDIT_RATIO.value);
}
function applyCreateSubAgentCreditRatio(ratioPercent: number) {
@@ -410,64 +398,97 @@ function resolveCreateParentLabel(agentId: string) {
return agentId;
}
/* ─── Init ─── */
function ensurePageInit(): Promise<void> {
if (pageInitLoaded.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();
void ensurePageInit();
loadActiveViewTabData();
});
// KeepAlive 激活时静默刷新当前 tab 列表(不重复 page-init
onActivated(() => {
void ensurePageInit();
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);
}
}
});
async function loadUsersPageInit() {
async function loadAgentSuspendDefaults() {
try {
const { data } = await api.get('/admin/users/page-init');
const payload = data.data as {
playerSettings?: typeof playerSettings.value;
bettingLimits?: typeof bettingLimits.value;
hierarchySettings?: { maxAgentLevel: number };
platformDirect?: { platformDirectRate?: number | string; adminInviteRate?: number | string };
agentLevelCounts?: Record<number, number>;
const { data } = await api.get('/admin/agents/settings/suspend');
agentSuspendDefaults.value = {
suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers),
suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin),
};
if (payload.playerSettings) playerSettings.value = payload.playerSettings;
if (payload.bettingLimits) bettingLimits.value = payload.bettingLimits;
if (payload.hierarchySettings) {
hierarchySettings.value = {
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
};
}
if (payload.platformDirect) {
platformDirectRate.value = decimalRateToPercent(payload.platformDirect.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(
payload.platformDirect.adminInviteRate ?? payload.platformDirect.platformDirectRate ?? 0,
);
}
if (payload.agentLevelCounts) {
agentLevelCounts.value = payload.agentLevelCounts;
for (const lvl of visibleSubAgentTabLevels.value) {
loadSubAgentsAtLevel(lvl);
}
}
settingsLoaded.value = true;
} catch {
/* keep defaults */
}
}
watch(settingsCollapseOpen, (open) => {
if (!open.includes('settings')) return;
if (!resetDbStatusLoaded.value) {
resetDbStatusLoaded.value = true;
void loadResetDatabaseStatus();
async function loadUsersPageInit() {
try {
const [pageInitRes] = await Promise.all([
api.get('/admin/users/page-init'),
loadAgentSuspendDefaults(),
]);
const { data } = pageInitRes;
const payload = data.data as {
hierarchySettings?: { maxAgentLevel: number; defaultSubAgentCreditRatio?: number };
agentLevelCounts?: Record<number, number>;
};
if (payload.hierarchySettings) {
hierarchySettings.value = {
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
defaultSubAgentCreditRatio: payload.hierarchySettings.defaultSubAgentCreditRatio ?? 50,
};
}
if (payload.agentLevelCounts) {
agentLevelCounts.value = payload.agentLevelCounts;
if (payload.agentLevelCounts[1] !== undefined) {
tier1Total.value = payload.agentLevelCounts[1];
}
}
pageInitLoaded.value = true;
} catch {
/* keep defaults */
}
if (!settingsLoaded.value) {
void loadUsersPageInit();
}
});
}
function openGlobalSettings() {
void router.push('/users/settings');
}
/* ─── Load tier-1 agents ─── */
async function loadTier1Agents() {
@@ -515,6 +536,9 @@ async function loadAgentLevelCounts() {
normalized[Number(lvl)] = Number(cnt) || 0;
}
agentLevelCounts.value = normalized;
if (normalized[1] !== undefined) {
tier1Total.value = normalized[1];
}
} catch {
agentLevelCounts.value = {};
}
@@ -648,223 +672,50 @@ function affiliationLabel(row: Pick<PlayerRow, 'affiliationAgents'>) {
return formatPlayerAffiliationLabel(row, t('user.type.player'), t('agent.platform_row_name'));
}
function directPlayersTabLabel(ownerName: string, count: number) {
return `${t('agent.direct_players_title', { name: ownerName })} (${count})`;
function eventClickElement(event: Event): Element | null {
const target = event.target;
if (target instanceof Element) return target;
if (target instanceof Node) return target.parentElement;
return null;
}
function onTier1AgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, event);
function openAgentDirectPlayers(row: AgentRow, _column: unknown, event: Event) {
const el = eventClickElement(event);
if (!el) return;
if (el.closest('button') || el.closest('.el-button') || el.closest('.admin-agent-row-actions')) return;
void router.push({
path: `/users/agents/${row.userId}/players`,
query: {
username: row.username,
fromTab: activeViewTab.value,
},
});
}
function onSubAgentRowClick(row: AgentRow, _column: unknown, event: MouseEvent) {
onAgentRowClick(row, event);
function agentRowClassName() {
return 'row-navigable';
}
watch(activeViewTab, (tab) => {
const m = /^agentLevel-(\d+)$/.exec(tab);
if (m) loadSubAgentsAtLevel(Number(m[1]));
});
watch(visibleSubAgentTabLevels, (levels, prev) => {
for (const lvl of levels) {
if (!prev?.includes(lvl) || !subAgentLevelState[lvl]?.agents.length) {
loadSubAgentsAtLevel(lvl);
}
}
});
/* ─── Expansion ─── */
async function onExpandChange(row: DisplayAgentRow, expandedRows: DisplayAgentRow[]) {
expandedSet.value = new Set(expandedRows.map((r) => r.userId));
if (expandedSet.value.has(row.userId) && !agentPlayersMap.value[row.userId]) {
await loadExpansionData(row.userId);
}
}
function onAgentRowClick(row: AgentRow, event: MouseEvent) {
if (!shouldToggleExpandOnRowClick(event)) return;
const userId = row.userId;
const next = new Set(expandedSet.value);
if (next.has(userId)) {
next.delete(userId);
} else {
if (next.size >= MAX_EXPANDED_AGENT_ROWS) {
const [first] = next;
if (first) next.delete(first);
}
next.add(userId);
if (!agentPlayersMap.value[userId]) void loadExpansionData(userId);
}
expandedSet.value = next;
}
async function loadExpansionData(agentId: string) {
expandLoading.value[agentId] = true;
try {
const { data } = await api.get('/admin/users', { params: { parentId: agentId, pageSize: 100 } });
agentPlayersMap.value[agentId] = data.data.items as PlayerRow[];
} catch {
agentPlayersMap.value[agentId] = [];
} finally {
expandLoading.value[agentId] = false;
}
}
function getPlayers(agentId: string) {
return agentPlayersMap.value[agentId] || [];
}
function refreshExpandedAgentPlayers() {
for (const agentId of expandedSet.value) {
loadExpansionData(agentId);
}
}
/* ─── Global settings ─── */
async function loadResetDatabaseStatus() {
try {
const { data } = await api.get('/admin/system/reset-database');
resetAllowed.value = !!data.data?.allowed;
} catch {
resetAllowed.value = false;
}
}
async function resetDatabase() {
if (resetConfirmPhrase.value !== 'RESET') {
ElMessage.warning(t('user.reset_database_confirm_label'));
if (m) {
void ensurePageInit().then(() => loadSubAgentsAtLevel(Number(m[1])));
return;
}
try {
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
type: 'warning',
confirmButtonText: t('user.reset_database_btn'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
resetLoading.value = true;
try {
const { data } = await api.post('/admin/system/reset-database', { confirmPhrase: 'RESET' });
const accounts: string[] = data.data?.demoAccounts ?? [];
ElMessage.success({
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
duration: 8000,
});
clearStaffSession();
await router.push('/login');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
resetLoading.value = false;
}
}
if (tab === 'players' && canViewUsers.value && !allPlayers.value.length) void loadAllPlayers();
if (tab === 'tier1Agents' && canViewAgents.value && !tier1Agents.value.length) void loadTier1Agents();
});
async function loadBettingLimits() {
try {
const { data } = await api.get('/admin/settings/betting-limits');
bettingLimits.value = data.data;
} catch {
/* defaults */
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);
}
}
async function saveBettingLimits() {
limitsSaving.value = true;
try {
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
bettingLimits.value = data.data;
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'));
loadBettingLimits();
} finally {
limitsSaving.value = false;
}
}
async function loadPlayerSettings() {
try {
const { data } = await api.get('/admin/users/settings/account');
playerSettings.value = data.data;
} catch {
/* defaults */
}
}
async function savePlayerSettings() {
settingsSaving.value = true;
try {
const { data } = await api.put('/admin/users/settings/account', playerSettings.value);
playerSettings.value = data.data;
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'));
loadPlayerSettings();
} finally {
settingsSaving.value = false;
}
}
async function loadHierarchySettings() {
try {
const { data } = await api.get('/admin/agents/settings/hierarchy');
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? 0 };
} catch {
hierarchySettings.value = { maxAgentLevel: 0 };
}
}
async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = { maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel };
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'));
loadHierarchySettings();
} finally {
hierarchySaving.value = false;
}
}
async function loadPlatformDirectSettings() {
try {
const { data } = await api.get('/admin/settings/cashback/platform-direct');
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
} catch {
platformDirectRate.value = 0;
adminInviteRate.value = 0;
}
}
async function savePlatformDirectSettings() {
platformDirectSaving.value = true;
try {
const { data } = await api.put('/admin/settings/cashback/platform-direct', {
platformDirectRate: percentToDecimalRate(platformDirectRate.value),
adminInviteRate: percentToDecimalRate(adminInviteRate.value),
});
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
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'));
loadPlatformDirectSettings();
} finally {
platformDirectSaving.value = false;
}
}
});
const walletLedgerVisible = ref(false);
const walletLedgerPlayerId = ref('');
@@ -987,7 +838,7 @@ async function submitCreate() {
}
const parentId = createParentAgentId.value || createForm.value.parentId;
if (parentId) {
await loadExpansionData(parentId);
directPlayersReload.value?.();
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -1280,8 +1131,8 @@ const freezeAgentIsSuspend = computed(() => {
function toggleFreezeAgent(row: AgentRow) {
freezeAgentTarget.value = row;
freezeAgentForm.value = {
freezeDirectPlayers: false,
blockDirectPlayerLogin: false,
freezeDirectPlayers: agentSuspendDefaults.value.suspendFreezeDirectPlayers,
blockDirectPlayerLogin: agentSuspendDefaults.value.suspendBlockPlayerLogin,
unfreezeDirectPlayers: false,
};
freezeAgentVisible.value = true;
@@ -1331,7 +1182,7 @@ async function submitFreezeAgent() {
function refreshExpandedParents() {
loadAllPlayers();
reloadAgentLists();
refreshExpandedAgentPlayers();
directPlayersReload.value?.();
}
const {
@@ -1402,107 +1253,40 @@ function creditTypeLabel(type: string) {
if (type === 'CREDIT_DECREASE') return t('agent.credit.decrease');
return type;
}
provide(agentPlayerActionsKey, {
get canCreatePlayer() {
return canCreateUsers.value;
},
get playerActionFlags() {
return playerActionFlags.value;
},
openCreatePlayer,
openDetailPlayer,
openEditPlayer,
openTransfer,
toggleFreezePlayer,
deletePlayer,
openPlayerWalletLedger,
});
</script>
<template>
<div class="admin-list-page agent-mgr-page">
<!-- Global settings collapse -->
<el-collapse v-if="canManageSettings" v-model="settingsCollapseOpen" class="list-settings">
<el-collapse-item :title="t('user.page_settings')" name="settings">
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.global_settings') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('user.field.allow_password_change')">
<el-switch v-model="playerSettings.allowPasswordChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
<el-form-item :label="t('user.field.allow_username_change')">
<el-switch v-model="playerSettings.allowUsernameChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('agent.hierarchy.settings_title') }}</p>
<p class="list-settings-hint">{{ t('agent.hierarchy.settings_hint') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('agent.hierarchy.max_level')">
<el-input-number
v-model="hierarchySettings.maxAgentLevel"
:min="0"
:step="1"
controls-position="right"
:disabled="hierarchySaving"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('cashback.settings_title') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('cashback.platform_direct_default_rate')">
<RatePercentInput v-model="platformDirectRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.platform_direct_default_hint') }}</p>
<el-form-item :label="t('cashback.admin_invite_default_rate')">
<RatePercentInput v-model="adminInviteRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.admin_invite_default_hint') }}</p>
<el-form-item>
<el-button type="primary" :loading="platformDirectSaving" @click="savePlatformDirectSettings">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
<el-form inline size="small" class="settings-form limits-form">
<el-form-item :label="t('user.limit.min_stake')">
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_single')">
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_parlay')">
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_single')">
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_parlay')">
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.daily_stake')">
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">{{ t('common.save') }}</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block list-settings-block--danger">
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
<el-alert v-if="!resetAllowed" type="warning" :closable="false" show-icon class="reset-db-alert" :title="t('user.reset_database_disabled_prod')" />
<el-form inline size="small" class="settings-form reset-db-form">
<el-form-item :label="t('user.reset_database_confirm_label')">
<el-input v-model="resetConfirmPhrase" :placeholder="t('user.reset_database_confirm_ph')" style="width: 160px" :disabled="!resetAllowed" autocomplete="off" />
</el-form-item>
<el-form-item>
<el-button type="danger" plain :loading="resetLoading" :disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'" @click="resetDatabase">{{ t('user.reset_database_btn') }}</el-button>
</el-form-item>
</el-form>
</div>
</el-collapse-item>
</el-collapse>
<div class="agent-mgr-shell">
<router-view v-if="isAgentChildRoute" />
<div v-else class="admin-list-page agent-mgr-page">
<InviteManageDialog v-model="inviteDialogOpen" />
<div class="mgr-tabs-shell">
<el-button v-if="canManageSettings" type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs" :class="{ 'mgr-top-tabs--with-invite': canManageSettings }">
<div v-if="canManageSettings" class="mgr-toolbar-actions">
<el-button class="settings-toolbar-btn" @click="openGlobalSettings">
{{ t('user.page_settings') }}
</el-button>
<el-button type="primary" class="invite-prominent-btn" @click="inviteDialogOpen = true">
{{ t('invite.menu_btn') }}
</el-button>
</div>
<el-tabs v-model="activeViewTab" class="mgr-top-tabs" :class="{ 'mgr-top-tabs--with-actions': canManageSettings }">
<!-- Tab: 全部玩家默认 -->
<el-tab-pane v-if="canViewUsers" :label="`${t('user.type.player')} (${playerTotal})`" name="players">
<section class="list-panel player-list-panel">
@@ -1557,11 +1341,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">
@@ -1642,83 +1426,21 @@ function creditTypeLabel(type: string) {
<el-button type="primary" @click="openCreateTier1Agent">{{ t('agent.create_btn') }}</el-button>
</div>
</div>
<p class="list-hint">{{ t('agent.open_agent_hint') }}</p>
<AdminTableWrap>
<el-table
:data="tier1Agents"
stripe
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="onTier1AgentRowClick"
:row-class-name="agentRowClassName"
class="compact-agent-table"
@row-click="openAgentDirectPlayers"
>
<template #empty>
<AdminTableEmpty />
</template>
<!-- Built-in expand column -->
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="expandLoading[row.userId]" class="expand-loading">
{{ t('common.loading') || '加载中...' }}
</div>
<div v-else class="expand-panel-body">
<div class="expand-section-header">
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
</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 prop="username" :label="t('user.col.username')" min-width="100" />
<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">
<span class="amount-compact">{{ formatAmount(player.availableBalance) }} / {{ formatAmount(player.frozenBalance) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('user.col.bets')" width="56" align="center" />
<el-table-column :label="t('user.col.stake_payout')" min-width="100" align="right">
<template #default="{ row: player }">
<span class="amount-compact">{{ formatAmount(player.totalStake) }} / {{ formatAmount(player.totalReturn) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="320" align="center">
<template #default="{ row: player }">
<AdminPlayerRowActions
v-bind="playerActionFlags"
:row="player"
@detail="openDetailPlayer(player.id)"
@ledger="openPlayerWalletLedger(player.id, player.username)"
@edit="openEditPlayer(player.id)"
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
@delete="deletePlayer(player)"
/>
</template>
</el-table-column>
</el-table>
</div>
</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 }">
@@ -1812,70 +1534,20 @@ function creditTypeLabel(type: string) {
</el-button>
</div>
</div>
<p class="list-hint">{{ t('agent.open_agent_hint') }}</p>
<AdminTableWrap>
<el-table
:data="ensureSubAgentState(agentLevel).agents"
stripe
row-key="userId"
:expand-row-keys="expandedRowKeys"
:row-class-name="expandableTableRowClassName"
class="expandable-table compact-agent-table"
@expand-change="onExpandChange"
@row-click="onSubAgentRowClick"
:row-class-name="agentRowClassName"
class="compact-agent-table"
@row-click="openAgentDirectPlayers"
>
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-panel">
<div v-if="expandLoading[row.userId]" class="expand-loading">{{ t('common.loading') }}</div>
<div v-else class="expand-panel-body">
<div class="expand-section-header">
<div class="expand-section-title">{{ directPlayersTabLabel(row.username, getPlayers(row.userId).length) }}</div>
<el-button type="primary" size="small" @click="openCreatePlayer(row.userId)">{{ t('user.create_btn') }}</el-button>
</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 prop="username" :label="t('user.col.username')" min-width="100" />
<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>
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" min-width="280" align="center">
<template #default="{ row: player }">
<AdminPlayerRowActions
v-bind="playerActionFlags"
:row="player"
@detail="openDetailPlayer(player.id)"
@ledger="openPlayerWalletLedger(player.id, player.username)"
@edit="openEditPlayer(player.id)"
@deposit="openTransfer('deposit', player)"
@withdraw="openTransfer('withdraw', player)"
@freeze="toggleFreezePlayer(player)"
@delete="deletePlayer(player)"
/>
</template>
</el-table-column>
</el-table>
</div>
</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>
@@ -1926,6 +1598,7 @@ function creditTypeLabel(type: string) {
</template>
</el-tabs>
</div>
</div>
<!-- DIALOGS -->
@@ -2492,6 +2165,23 @@ function creditTypeLabel(type: string) {
</template>
<style scoped>
.agent-mgr-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.agent-mgr-shell > :deep(.agent-direct-players-page),
.agent-mgr-shell > :deep(.global-settings-page) {
flex: 1;
min-height: 0;
}
.compact-agent-table :deep(.row-navigable) {
cursor: pointer;
}
.mgr-tabs-shell {
position: relative;
flex: 1;
@@ -2500,15 +2190,31 @@ function creditTypeLabel(type: string) {
flex-direction: column;
}
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
padding-right: 108px;
.mgr-top-tabs--with-actions :deep(.el-tabs__header) {
padding-right: 248px;
}
.mgr-toolbar-actions {
position: absolute;
right: 16px;
top: 14px;
z-index: 2;
display: flex;
align-items: center;
gap: 8px;
}
.settings-toolbar-btn {
min-width: 96px;
height: 38px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
border-radius: 8px;
}
.invite-prominent-btn {
position: absolute;
right: 0;
top: 0;
z-index: 2;
position: static;
min-width: 96px;
height: 38px;
padding: 0 22px;
@@ -2555,12 +2261,9 @@ function creditTypeLabel(type: string) {
display: flex;
flex-direction: column;
}
.mgr-top-tabs :deep(.el-tabs__header) {
margin-bottom: 8px;
}
.mgr-top-tabs :deep(.el-tabs__item) {
font-size: 14px;
font-weight: 500;
font-weight: 700;
}
.freeze-agent-intro {
@@ -2575,19 +2278,7 @@ function creditTypeLabel(type: string) {
gap: 10px;
}
/* ─── Table toolbar ─── */
.list-panel-toolbar {
flex-shrink: 0;
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 10px 0 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
--list-chrome-control-h: 32px;
--el-component-size: 32px;
}
/* ─── Table toolbar(间距见 App.vue 全局 .list-panel-toolbar ─── */
.list-panel-toolbar .list-chrome__grow {
flex: 1 1 280px;
min-width: 0;
@@ -2597,19 +2288,6 @@ function creditTypeLabel(type: string) {
.list-panel-toolbar .list-chrome__actions {
flex-shrink: 0;
}
.list-panel-toolbar :deep(.el-form-item) {
margin-bottom: 0 !important;
margin-right: 12px;
}
.list-panel-toolbar :deep(.el-input__wrapper),
.list-panel-toolbar :deep(.el-select__wrapper) {
height: var(--list-chrome-control-h) !important;
min-height: var(--list-chrome-control-h) !important;
}
.list-panel-toolbar :deep(.el-button:not(.is-link)) {
height: var(--list-chrome-control-h) !important;
min-height: var(--list-chrome-control-h) !important;
}
/* ─── Expansion ─── */
.expand-panel {
@@ -2714,11 +2392,11 @@ function creditTypeLabel(type: string) {
}
@media (max-width: 900px) {
.mgr-top-tabs--with-invite :deep(.el-tabs__header) {
.mgr-top-tabs--with-actions :deep(.el-tabs__header) {
padding-right: 0;
}
.invite-prominent-btn {
.mgr-toolbar-actions {
position: static;
align-self: flex-end;
margin: 0 0 8px;
@@ -2843,14 +2521,6 @@ function creditTypeLabel(type: string) {
gap: 8px;
}
.mgr-top-tabs :deep(.el-tabs__item) {
font-weight: 700;
}
.list-panel-toolbar {
border-bottom-color: var(--border-soft);
}
.expand-panel {
background: #fbfaf7;
border-color: var(--border);

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, sanitizeAnnouncementHtml, isHtmlEmpty } 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,81 @@ 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;
banner: boolean;
announcement: boolean;
}
interface MessageBroadcastItem {
id: string;
title: string;
body: string;
translations?: Record<string, { title: string; body: string }>;
targetType: 'ALL' | 'USER';
targetUserId: string | null;
targetUsername: string | null;
recipientCount: number;
createdById: string | null;
createdByUsername: string | null;
createdAt: string;
}
interface BroadcastTranslationForm {
locale: string;
title: string;
body: string;
}
const inboxNotifySettings = ref<InboxNotifySettings>({
inboxEnabled: true,
deposit: true,
banner: true,
announcement: true,
});
const inboxNotifySaving = ref(false);
const broadcastLoading = ref(false);
const broadcastSending = ref(false);
const broadcastDialogVisible = ref(false);
const broadcastActiveLocale = ref<string>('zh-CN');
const broadcastEditorRef = ref<InstanceType<typeof ContentRichEditor> | null>(null);
const broadcastItems = ref<MessageBroadcastItem[]>([]);
const broadcastTotal = ref(0);
const broadcastPage = ref(1);
const broadcastPageSize = ref(10);
const broadcastDetailVisible = ref(false);
const broadcastDetailRow = ref<MessageBroadcastItem | null>(null);
const broadcastDetailLocale = ref<string>('zh-CN');
function emptyBroadcastTranslations(): BroadcastTranslationForm[] {
return LOCALES.map((locale) => ({
locale,
title: '',
body: '',
}));
}
const broadcastForm = ref({
targetType: 'ALL' as 'ALL' | 'USER',
targetUsername: '',
translations: emptyBroadcastTranslations(),
});
const broadcastActiveTranslation = computed(
() =>
broadcastForm.value.translations.find((tr) => tr.locale === broadcastActiveLocale.value) ??
broadcastForm.value.translations[0],
);
const form = ref({
sortOrder: 0,
@@ -200,12 +194,226 @@ 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),
banner: data.data?.banner !== false,
announcement: data.data?.announcement !== false,
};
} 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,
banner: inboxNotifySettings.value.banner,
announcement: inboxNotifySettings.value.announcement,
});
inboxNotifySettings.value = {
inboxEnabled: data.data?.inboxEnabled !== false,
deposit: Boolean(data.data?.deposit),
banner: data.data?.banner !== false,
announcement: data.data?.announcement !== false,
};
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 loadBroadcasts() {
broadcastLoading.value = true;
try {
const { data } = await api.get('/admin/player-message-broadcasts', {
params: { page: broadcastPage.value, pageSize: broadcastPageSize.value },
});
broadcastItems.value = data.data?.items ?? [];
broadcastTotal.value = data.data?.total ?? 0;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
broadcastLoading.value = false;
}
}
function resetBroadcastForm() {
broadcastForm.value = {
targetType: 'ALL',
targetUsername: '',
translations: emptyBroadcastTranslations(),
};
broadcastActiveLocale.value = 'zh-CN';
}
function hasBroadcastContent() {
return broadcastForm.value.translations.some(
(tr) => tr.title.trim() || tr.body.trim(),
);
}
function openBroadcastDialog() {
resetBroadcastForm();
broadcastDialogVisible.value = true;
}
function closeBroadcastDialog() {
if (broadcastSending.value) return;
broadcastDialogVisible.value = false;
}
async function sendBroadcast() {
if (!canManageContent.value) return;
const editor = broadcastEditorRef.value;
if (editor) {
broadcastActiveTranslation.value.body = editor.getHtml();
for (const tr of broadcastForm.value.translations) {
tr.body = await editor.uploadPendingImages(tr.body);
}
}
if (!hasBroadcastContent()) {
ElMessage.warning(t('content.inbox_broadcast.form_invalid'));
return;
}
if (broadcastForm.value.targetType === 'USER' && !broadcastForm.value.targetUsername.trim()) {
ElMessage.warning(t('content.inbox_broadcast.target_user_required'));
return;
}
broadcastSending.value = true;
try {
const { data } = await api.post('/admin/player-message-broadcasts', {
translations: broadcastForm.value.translations.map((tr) => ({
locale: tr.locale,
title: tr.title.trim() || undefined,
body: tr.body.trim() || undefined,
})),
targetType: broadcastForm.value.targetType,
targetUsername:
broadcastForm.value.targetType === 'USER'
? broadcastForm.value.targetUsername.trim()
: undefined,
});
ElMessage.success(
t('content.inbox_broadcast.send_success', {
n: data.data?.recipientCount ?? 0,
}),
);
resetBroadcastForm();
broadcastPage.value = 1;
broadcastDialogVisible.value = false;
await loadBroadcasts();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
broadcastSending.value = false;
}
}
function broadcastTargetLabel(row: MessageBroadcastItem) {
if (row.targetType === 'ALL') return t('content.inbox_broadcast.target_all');
return row.targetUsername || `#${row.targetUserId ?? ''}`;
}
function formatBroadcastTime(value: string) {
if (!value) return '—';
return new Date(value).toLocaleString(localeTag.value);
}
function openBroadcastDetail(row: MessageBroadcastItem) {
broadcastDetailRow.value = row;
const tr = row.translations ?? {};
const firstWithContent =
LOCALES.find((locale) => {
const item = tr[locale];
return item && (item.title?.trim() || !isHtmlEmpty(item.body));
}) ?? 'zh-CN';
broadcastDetailLocale.value = firstWithContent;
broadcastDetailVisible.value = true;
}
function broadcastDetailTranslation(locale: string) {
const row = broadcastDetailRow.value;
if (!row) return { title: '', body: '' };
return row.translations?.[locale] ?? { title: '', body: '' };
}
async function deleteBroadcast(row: MessageBroadcastItem) {
if (!canManageContent.value) return;
try {
await ElMessageBox.confirm(
t('content.inbox_broadcast.delete_confirm', { title: row.title }),
t('common.confirm'),
{ type: 'warning' },
);
} catch {
return;
}
broadcastLoading.value = true;
try {
await api.delete(`/admin/player-message-broadcasts/${row.id}`);
ElMessage.success(t('msg.saved'));
if (broadcastItems.value.length === 1 && broadcastPage.value > 1) {
broadcastPage.value -= 1;
}
await loadBroadcasts();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
broadcastLoading.value = false;
}
}
function onBroadcastPageChange(page: number) {
broadcastPage.value = page;
void loadBroadcasts();
}
function onBroadcastSizeChange(size: number) {
broadcastPageSize.value = size;
broadcastPage.value = 1;
void loadBroadcasts();
}
async function load() {
loading.value = true;
try {
@@ -244,9 +452,23 @@ watch([activeType, filterStatus], () => {
page.value = 1;
selectedRows.value = [];
tableRef.value?.clearSelection();
if (activeType.value === 'INBOX_NOTIFY') {
void loadInboxNotifySettings();
void loadBroadcasts();
return;
}
void load();
});
onActivated(() => {
if (activeType.value === 'INBOX_NOTIFY') {
void loadInboxNotifySettings();
if (broadcastItems.value.length > 0) void loadBroadcasts();
return;
}
if (items.value.length > 0) void load();
});
function onSelectionChange(rows: ContentItem[]) {
selectedRows.value = rows;
}
@@ -311,6 +533,8 @@ function batchDelete() {
}
function resetForm() {
activeLocale.value = 'zh-CN';
notifyInbox.value = false;
form.value = {
sortOrder: 0,
status: 'DRAFT',
@@ -322,15 +546,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 +557,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 +615,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 +735,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 +768,78 @@ 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>
<div v-show="inboxNotifySettings.inboxEnabled" class="inbox-notify-row">
<div class="inbox-notify-row-text">
<span class="inbox-notify-label">{{ t('content.inbox_notify.banner') }}</span>
<span class="inbox-notify-hint">{{ t('content.inbox_notify.banner_hint') }}</span>
</div>
<el-switch
v-model="inboxNotifySettings.banner"
: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.announcement') }}</span>
<span class="inbox-notify-hint">{{ t('content.inbox_notify.announcement_hint') }}</span>
</div>
<el-switch
v-model="inboxNotifySettings.announcement"
: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 v-if="inboxNotifySettings.inboxEnabled" class="inbox-toolbar">
<el-button
v-if="canManageContent"
type="primary"
size="small"
@click="openBroadcastDialog"
>
{{ t('content.inbox_broadcast.title') }}
</el-button>
<span class="inbox-toolbar-hint">{{ t('content.inbox_broadcast.hint') }}</span>
</div>
</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 +857,97 @@ 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 && inboxNotifySettings.inboxEnabled"
v-loading="broadcastLoading"
class="data-card"
shadow="never"
>
<div class="table-wrap">
<el-table :data="broadcastItems" row-key="id" stripe size="small">
<template #empty>
<AdminTableEmpty />
</template>
<el-table-column
type="index"
:index="(i: number) => (broadcastPage - 1) * broadcastPageSize + i + 1"
:label="t('common.seq')"
width="70"
align="center"
/>
<el-table-column :label="t('content.inbox_broadcast.col_title')" min-width="160" prop="title" />
<el-table-column :label="t('content.inbox_broadcast.col_target')" width="140">
<template #default="{ row }">
{{ broadcastTargetLabel(row) }}
</template>
</el-table-column>
<el-table-column
:label="t('content.inbox_broadcast.col_recipients')"
width="90"
align="center"
prop="recipientCount"
/>
<el-table-column :label="t('content.inbox_broadcast.col_sender')" width="120">
<template #default="{ row }">
{{ row.createdByUsername || '—' }}
</template>
</el-table-column>
<el-table-column :label="t('content.inbox_broadcast.col_time')" width="170">
<template #default="{ row }">
{{ formatBroadcastTime(row.createdAt) }}
</template>
</el-table-column>
<el-table-column :label="t('common.actions')" width="130" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openBroadcastDetail(row)">
{{ t('common.detail') }}
</el-button>
<el-button
v-if="canManageContent"
type="danger"
link
size="small"
@click="deleteBroadcast(row)"
>
{{ t('common.delete') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div v-if="broadcastTotal > 0" class="pager-row">
<el-pagination
v-model:current-page="broadcastPage"
v-model:page-size="broadcastPageSize"
:total="broadcastTotal"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next"
background
small
@current-change="onBroadcastPageChange"
@size-change="onBroadcastSizeChange"
/>
</div>
</el-card>
<el-card v-if="!isInboxNotifyTab" v-loading="loading" class="data-card" shadow="never">
<div class="table-wrap">
<el-table
ref="tableRef"
@@ -523,21 +961,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 +998,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 +1020,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 +1044,395 @@ 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>
<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>
<el-dialog
v-model="broadcastDetailVisible"
:title="t('content.inbox_broadcast.view_title')"
width="min(760px, 96vw)"
destroy-on-close
append-to-body
class="inbox-broadcast-detail-dialog"
>
<template v-if="broadcastDetailRow">
<dl class="broadcast-detail-meta">
<div class="meta-row">
<dt>{{ t('content.inbox_broadcast.col_target') }}</dt>
<dd>{{ broadcastTargetLabel(broadcastDetailRow) }}</dd>
</div>
<div class="media-picker-name" :title="file.filename">{{ file.filename }}</div>
</div>
</div>
<div class="meta-row">
<dt>{{ t('content.inbox_broadcast.col_recipients') }}</dt>
<dd>{{ broadcastDetailRow.recipientCount }}</dd>
</div>
<div class="meta-row">
<dt>{{ t('content.inbox_broadcast.col_sender') }}</dt>
<dd>{{ broadcastDetailRow.createdByUsername || '—' }}</dd>
</div>
<div class="meta-row">
<dt>{{ t('content.inbox_broadcast.col_time') }}</dt>
<dd>{{ formatBroadcastTime(broadcastDetailRow.createdAt) }}</dd>
</div>
</dl>
<el-tabs v-model="broadcastDetailLocale" class="locale-tabs">
<el-tab-pane
v-for="locale in LOCALES"
:key="locale"
:label="localeLabel(locale)"
:name="locale"
>
<div class="broadcast-detail-block">
<div class="broadcast-detail-label">{{ t('content.inbox_broadcast.field_title') }}</div>
<div class="broadcast-detail-title">
{{ broadcastDetailTranslation(locale).title || '—' }}
</div>
</div>
<div class="broadcast-detail-block">
<div class="broadcast-detail-label">{{ t('content.inbox_broadcast.field_body') }}</div>
<div
v-if="!isHtmlEmpty(broadcastDetailTranslation(locale).body)"
class="broadcast-detail-body rich-html"
v-html="sanitizeAnnouncementHtml(broadcastDetailTranslation(locale).body)"
/>
<div v-else class="broadcast-detail-empty"></div>
</div>
</el-tab-pane>
</el-tabs>
</template>
<template #footer>
<el-button @click="broadcastDetailVisible = false">{{ t('common.close') }}</el-button>
</template>
</el-dialog>
<el-dialog
v-model="broadcastDialogVisible"
:title="t('content.inbox_broadcast.title')"
width="min(920px, 96vw)"
destroy-on-close
:close-on-click-modal="!broadcastSending"
class="inbox-broadcast-dialog content-publish-dialog"
@close="closeBroadcastDialog"
>
<el-form label-position="top" class="inbox-broadcast-form" @submit.prevent>
<el-form-item :label="t('content.inbox_broadcast.field_target')">
<el-radio-group v-model="broadcastForm.targetType" :disabled="broadcastSending">
<el-radio value="ALL">{{ t('content.inbox_broadcast.target_all') }}</el-radio>
<el-radio value="USER">{{ t('content.inbox_broadcast.target_user') }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="broadcastForm.targetType === 'USER'"
:label="t('content.inbox_broadcast.field_username')"
>
<el-input
v-model="broadcastForm.targetUsername"
:placeholder="t('content.inbox_broadcast.username_placeholder')"
:disabled="broadcastSending"
/>
</el-form-item>
<section class="broadcast-content-section">
<div class="broadcast-content-head">
<h3 class="section-title">{{ t('content.section.content') }}</h3>
<p class="field-hint">{{ t('content.inbox_broadcast.locale_fallback_hint') }}</p>
</div>
<el-tabs v-model="broadcastActiveLocale" class="locale-tabs">
<el-tab-pane
v-for="tr in broadcastForm.translations"
:key="tr.locale"
:label="localeLabel(tr.locale)"
:name="tr.locale"
>
<el-form-item :label="t('content.inbox_broadcast.field_title')">
<el-input
v-model="tr.title"
maxlength="256"
show-word-limit
:placeholder="t('content.field.title_ph')"
:disabled="broadcastSending"
/>
</el-form-item>
</el-tab-pane>
</el-tabs>
<div class="broadcast-editor-head">
<span class="publish-body-label">{{ t('content.inbox_broadcast.field_body') }}</span>
<span class="locale-badge">{{ localeLabel(broadcastActiveLocale) }}</span>
</div>
<ContentRichEditor
ref="broadcastEditorRef"
v-model="broadcastActiveTranslation.body"
fill
upload-category="contents"
:placeholder="t('content.editor.placeholder')"
:disabled="broadcastSending"
class="broadcast-rich-editor"
/>
</section>
</el-form>
<template #footer>
<el-button :disabled="broadcastSending" @click="closeBroadcastDialog">
{{ t('common.cancel') }}
</el-button>
<el-button type="primary" :loading="broadcastSending" @click="sendBroadcast">
{{ t('content.inbox_broadcast.send') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -753,11 +1442,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 +1471,6 @@ void load();
.batch-hint {
font-size: 12px;
color: #888;
margin: 0 4px;
white-space: nowrap;
}
@@ -776,313 +1480,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 +1739,179 @@ 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;
}
.inbox-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-top: 4px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.inbox-toolbar-hint {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
}
.inbox-broadcast-form :deep(.el-form-item) {
margin-bottom: 14px;
}
.broadcast-content-section {
margin-top: 4px;
}
.broadcast-content-head {
margin-bottom: 8px;
}
.broadcast-content-head .section-title {
margin: 0 0 4px;
}
.broadcast-editor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 12px 0 8px;
}
.broadcast-rich-editor {
min-height: 280px;
}
.inbox-broadcast-dialog :deep(.el-dialog__body) {
padding-top: 8px;
}
.broadcast-detail-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 16px;
margin: 0 0 16px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fbfaf7;
}
.meta-row {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.meta-row dt {
margin: 0;
font-size: 11px;
color: var(--text-muted);
}
.meta-row dd {
margin: 0;
font-size: 13px;
color: var(--text);
word-break: break-word;
}
.broadcast-detail-block + .broadcast-detail-block {
margin-top: 14px;
}
.broadcast-detail-label {
margin-bottom: 6px;
font-size: 12px;
color: var(--text-muted);
}
.broadcast-detail-title {
font-size: 15px;
font-weight: 600;
line-height: 1.45;
word-break: break-word;
}
.broadcast-detail-body,
.broadcast-detail-empty {
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
min-height: 80px;
}
.broadcast-detail-empty {
color: var(--text-muted);
}
.rich-html :deep(img) {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.rich-html :deep(p) {
margin: 0 0 0.75em;
}
.rich-html :deep(p:last-child) {
margin-bottom: 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,14 +304,18 @@ 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>
<td class="amount">{{ formatAmount(row.amount) }}</td>
<td>
<span v-if="row.screenshotUrl === '/uploads/defaults/expired.png'" class="expired-screenshot-tag">
{{ t('media.cleanup_expired_tag') || '已清理' }}
</span>
<img
v-if="row.screenshotUrl"
v-else-if="row.screenshotUrl"
:src="row.screenshotUrl"
class="screenshot-thumb"
@click="openScreenshot(row.screenshotUrl)"
@@ -353,7 +372,10 @@ onMounted(fetchList);
</div>
<div class="info-row">
<span>{{ t('deposit.screenshot') }}:</span>
<img :src="approveTarget.screenshotUrl" class="approve-screenshot" @click="openScreenshot(approveTarget.screenshotUrl)" />
<div v-if="approveTarget.screenshotUrl === '/uploads/defaults/expired.png'" class="expired-screenshot-box">
<span class="expired-text">{{ t('media.cleanup_expired_tag') || '已清理' }}</span>
</div>
<img v-else :src="approveTarget.screenshotUrl" class="approve-screenshot" @click="openScreenshot(approveTarget.screenshotUrl)" />
</div>
<div class="form-group">
<label>{{ t('deposit.approved_amount_label') }}</label>
@@ -445,6 +467,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; }
@@ -729,6 +769,37 @@ onMounted(fetchList);
color: #ffffff;
}
.expired-screenshot-tag {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
font-size: 11px;
color: var(--text-muted);
background: var(--accent-hover);
border: 1px dashed var(--border);
border-radius: 6px;
user-select: none;
font-weight: 700;
}
.expired-screenshot-box {
width: 120px;
height: 120px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--accent-hover);
border: 1px dashed var(--border);
border-radius: 6px;
margin-top: 4px;
}
.expired-screenshot-box .expired-text {
font-size: 12px;
color: var(--text-muted);
font-weight: 700;
}
@media (max-width: 700px) {
.toolbar,
.filters {

View File

@@ -1,13 +1,16 @@
<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';
import api from '../api';
import AdminTableEmpty from '../components/AdminTableEmpty.vue';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import { walletTxTypeKey } from '../utils/walletTx';
const { t, locale, localeTag } = useAdminLocale();
import { formatAdminDateTimeBrief, formatAdminDateTimeFull } from '../utils/format-datetime';
import { walletTxTypeKey, walletRemarkLabel } from '../utils/walletTx';
const { t, locale } = useAdminLocale();
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
@@ -47,12 +50,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('');
@@ -80,36 +85,8 @@ function transferTypeLabel(type: string) {
return key ? t(key) : type;
}
const TRANSFER_REMARK_KEYS: Record<string, string> = {
'Agent deposit': 'finance.remark.agent_deposit',
'Agent withdraw': 'finance.remark.agent_withdraw',
'代理上分': 'finance.remark.agent_deposit',
'代理下分': 'finance.remark.agent_withdraw',
'管理员上分': 'finance.remark.admin_deposit',
'管理员下分': 'finance.remark.admin_withdraw',
'开户初始余额': 'finance.remark.initial_balance',
};
function transferRemarkLabel(remark: string | null | undefined, transactionType: string) {
const raw = remark?.trim();
if (!raw) {
if (transactionType === 'MANUAL_DEPOSIT') return t('finance.remark.agent_deposit');
if (transactionType === 'MANUAL_WITHDRAW') return t('finance.remark.agent_withdraw');
return '—';
}
const key = TRANSFER_REMARK_KEYS[raw];
return key ? t(key) : raw;
}
function formatTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
return walletRemarkLabel(remark, transactionType, t);
}
function dateParams() {
@@ -123,7 +100,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 +116,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 +139,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 +191,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,14 +305,19 @@ 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 :label="t('audit.col.time')" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</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="88">
<template #default="{ row }">
<el-tooltip :content="formatAdminDateTimeFull(row.createdAt, locale)" placement="top">
<span>{{ formatAdminDateTimeBrief(row.createdAt, locale) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('user.col.username')" min-width="110">
<template #default="{ row }">
@@ -384,14 +380,19 @@ 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 :label="t('audit.col.time')" min-width="158">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</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="88">
<template #default="{ row }">
<el-tooltip :content="formatAdminDateTimeFull(row.createdAt, locale)" placement="top">
<span>{{ formatAdminDateTimeBrief(row.createdAt, locale) }}</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column :label="t('finance.col.tx_id')" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ row.transactionId }}</template>

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,12 +1,16 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, watch, onBeforeUnmount, onDeactivated } from 'vue';
defineOptions({ name: 'AdminMatches' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import { consumeAdminListStale } from '../utils/adminListStale';
import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import { resolveFormError } from '../i18n/form-validation';
import api from '../api';
import { ElMessage, ElMessageBox } from 'element-plus';
import LeagueMatchesPanel from './matches/LeagueMatchesPanel.vue';
import MatchesSubNav from '../components/MatchesSubNav.vue';
import LeagueRowActions from '../components/LeagueRowActions.vue';
import CountryFlagSelect from '../components/outright/CountryFlagSelect.vue';
import LogoUrlField from '../components/LogoUrlField.vue';
import LeagueArchiveDialog from '../components/LeagueArchiveDialog.vue';
@@ -14,7 +18,6 @@ import { getBuiltinCountry } from '../data/builtinCountries';
import {
readMatchesListUiState,
writeMatchesListUiState,
MAX_EXPANDED_LEAGUES,
} from '../utils/matchesListState';
import { formatAmount } from '../utils/format-amount';
import {
@@ -25,15 +28,45 @@ import {
type MatchCreateForm,
} from './match-form';
const { t } = useAdminLocale();
const { t, localeTag } = useAdminLocale();
const route = useRoute();
const leagues = ref<unknown[]>([]);
const router = useRouter();
const isMatchChildRoute = computed(() =>
/^\/matches\/leagues\/[^/]+/.test(route.path),
);
interface LeagueTableRow extends Record<string, unknown> {
id: string;
isPublished: boolean;
isOutrightSettled: boolean;
isPublishing: boolean;
labels: {
edit: string;
createFixture: string;
publish: string;
unpublish: string;
delete: string;
};
displaySeq: number;
displayNameZh: string;
displayNameEn: string;
displayStatusLabel: string;
displayStatusTagType: 'success' | 'info' | 'warning';
displayMatchCount: number;
displayBetCount: number;
displayBetCountActive: boolean;
displayTotalStake: string;
displayPendingBets: number;
displayCode: string;
}
const leagues = ref<LeagueTableRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const filterStatus = ref('');
const keyword = ref('');
const expandedRowKeys = ref<string[]>([]);
const createLeagueVisible = ref(false);
const createLeagueLoading = ref(false);
@@ -58,9 +91,76 @@ const createUnderLeagueLabel = ref('');
const isFixtureCreate = computed(() => !!form.value.leagueId.trim());
function rowOf(row: unknown) {
return row as Record<string, unknown>;
}
function leagueId(row: unknown) {
return String(rowOf(row).id ?? '');
}
function leagueTitle(row: unknown) {
const r = rowOf(row);
const zh = String(r.leagueZh ?? '').trim();
const en = String(r.leagueEn ?? '').trim();
return zh || en || String(r.code ?? '—');
}
function leagueActionLabels() {
return {
edit: t('common.edit'),
createFixture: t('match.create_fixture_btn'),
publish: t('common.publish'),
unpublish: t('league.btn.unpublish'),
delete: t('common.delete'),
};
}
function mapLeagueRows(items: unknown[], publishingId = ''): LeagueTableRow[] {
const labels = leagueActionLabels();
const start = (page.value - 1) * pageSize.value;
return items.map((item, index) => {
const r = rowOf(item);
const id = String(r.id ?? '');
const published = Boolean(r.isPublished);
const outrightSettled = Boolean(r.isOutrightSettled);
const stats = r.betStats as
| { betCount?: number; totalStake?: string; pendingCount?: number }
| undefined;
const betCount = Number(stats?.betCount ?? 0);
return {
...r,
id,
isPublished: published,
isOutrightSettled: outrightSettled,
isPublishing: publishingId === id,
labels,
displaySeq: start + index + 1,
displayNameZh: String(r.leagueZh ?? '').trim() || '—',
displayNameEn: String(r.leagueEn ?? '').trim() || '—',
displayStatusLabel: outrightSettled
? t('league.status.OUTRIGHT_SETTLED')
: published
? t('league.status.PUBLISHED')
: t('league.status.UNPUBLISHED'),
displayStatusTagType: outrightSettled ? 'warning' : published ? 'success' : 'info',
displayMatchCount: Number(r.matchCount ?? 0),
displayBetCount: betCount,
displayBetCountActive: betCount > 0,
displayTotalStake: formatAmount(String(stats?.totalStake ?? '0')),
displayPendingBets: Number(stats?.pendingCount ?? 0),
displayCode: String(r.code ?? ''),
};
});
}
function remapLeagueRows(publishingId = publishingLeagueId.value) {
if (!leagues.value.length) return;
leagues.value = mapLeagueRows(leagues.value, publishingId);
}
function persistListUiState() {
writeMatchesListUiState({
expandedLeagueIds: [...expandedRowKeys.value],
page: page.value,
pageSize: pageSize.value,
filterStatus: filterStatus.value,
@@ -68,15 +168,11 @@ function persistListUiState() {
});
}
function applyExpandedFromSaved(savedIds: string[]) {
const allowed = new Set(leagues.value.map((row) => leagueId(row)));
expandedRowKeys.value = savedIds.filter((id) => allowed.has(id));
}
type LoadOptions = { restoreExpand?: boolean; keepExpand?: boolean };
type LoadOptions = { restore?: boolean };
async function load(options: LoadOptions = {}) {
const saved = options.restoreExpand ? readMatchesListUiState() : null;
consumeAdminListStale();
const saved = options.restore ? readMatchesListUiState() : null;
if (saved) {
page.value = saved.page;
pageSize.value = saved.pageSize;
@@ -92,46 +188,59 @@ async function load(options: LoadOptions = {}) {
keyword: keyword.value.trim() || undefined,
},
});
leagues.value = data.data.items;
leagues.value = mapLeagueRows(data.data.items);
total.value = data.data.total;
if (options.restoreExpand && saved) {
applyExpandedFromSaved(saved.expandedLeagueIds);
} else if (!options.keepExpand) {
expandedRowKeys.value = [];
} else {
applyExpandedFromSaved(expandedRowKeys.value);
}
persistListUiState();
}
function onSearch() {
page.value = 1;
expandedRowKeys.value = [];
load();
void runLoad(true);
}
onMounted(() => {
const MATCH_CHILD_ROUTE = /^\/matches\/leagues\/[^/]+/;
watch(
() => route.path,
(path, prevPath) => {
if (prevPath && MATCH_CHILD_ROUTE.test(prevPath) && path === '/matches') {
void load();
}
},
);
async function initialLoad() {
if (isMatchChildRoute.value) return;
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 });
});
const qLeague = route.query.leagueId;
if (typeof qLeague === 'string' && qLeague.trim()) {
router.replace(`/matches/leagues/${qLeague.trim()}`);
return;
}
await load({ restore: true });
}
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad);
onBeforeUnmount(persistListUiState);
onDeactivated(persistListUiState);
watch(localeTag, () => remapLeagueRows());
function onPageChange(p: number) {
page.value = p;
load({ keepExpand: true });
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load({ keepExpand: true });
load();
}
function openCreateLeague() {
@@ -173,6 +282,7 @@ async function toggleLeaguePublish(row: unknown) {
}
}
publishingLeagueId.value = id;
remapLeagueRows(id);
try {
await api.put(`/admin/leagues/${id}`, {
leagueEn: String(r.leagueEn ?? ''),
@@ -182,7 +292,7 @@ async function toggleLeaguePublish(row: unknown) {
isActive: !published,
});
ElMessage.success(published ? t('msg.league_unpublished') : t('msg.league_published'));
await load({ keepExpand: true });
await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -225,7 +335,7 @@ async function submitLeagueForm() {
}
createLeagueVisible.value = false;
load({ keepExpand: true });
load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -269,10 +379,16 @@ async function submitCreate() {
createUnderLeagueLabel.value = '';
createVisible.value = false;
const lid = form.value.leagueId.trim();
await load({ keepExpand: true });
if (lid && !expandedRowKeys.value.includes(lid)) {
expandedRowKeys.value = capExpandedLeagueIds([...expandedRowKeys.value, lid]);
persistListUiState();
await load();
if (lid) {
router.push({
path: `/matches/leagues/${lid}`,
query: {
...(filterStatus.value ? { status: filterStatus.value } : {}),
...(keyword.value.trim() ? { keyword: keyword.value.trim() } : {}),
title: createUnderLeagueLabel.value || leagueTitle({ id: lid }),
},
});
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
@@ -282,88 +398,32 @@ async function submitCreate() {
}
}
function capExpandedLeagueIds(ids: string[]): string[] {
return ids.slice(0, MAX_EXPANDED_LEAGUES);
}
function onExpandChange(_row: unknown, expanded: unknown[]) {
expandedRowKeys.value = capExpandedLeagueIds(expanded.map((r) => leagueId(r)));
persistListUiState();
}
function onRowClick(row: unknown, _column: unknown, event: MouseEvent) {
if ((event.target as HTMLElement).closest('.el-table__expand-icon')) return;
function openLeaguePage(row: unknown, _column: unknown, event: Event) {
const target = event.target;
const el = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!el) return;
if (el.closest('.league-row-actions') || el.closest('.el-button')) return;
const id = leagueId(row);
if (expandedRowKeys.value.includes(id)) {
expandedRowKeys.value = expandedRowKeys.value.filter((k) => k !== id);
} else {
const next = [...expandedRowKeys.value, id];
expandedRowKeys.value = capExpandedLeagueIds(next);
}
persistListUiState();
if (!id) return;
void router.push({
name: 'admin-league-matches',
params: { leagueId: id },
query: {
...(filterStatus.value ? { status: filterStatus.value } : {}),
...(keyword.value.trim() ? { keyword: keyword.value.trim() } : {}),
title: leagueTitle(row),
},
});
}
function rowClassName() {
return 'row-expandable';
}
function rowOf(row: unknown) {
return row as Record<string, unknown>;
}
function leagueId(row: unknown) {
return String(rowOf(row).id ?? '');
}
function leagueTitle(row: unknown) {
const r = rowOf(row);
const zh = String(r.leagueZh ?? '').trim();
const en = String(r.leagueEn ?? '').trim();
return zh || en || String(r.code ?? '—');
}
function leagueNameZh(row: unknown) {
const zh = String(rowOf(row).leagueZh ?? '').trim();
return zh || '—';
}
function leagueNameEn(row: unknown) {
const en = String(rowOf(row).leagueEn ?? '').trim();
return en || '—';
}
function leagueMatchCount(row: unknown) {
return Number(rowOf(row).matchCount ?? 0);
return 'row-navigable';
}
function leagueIsPublished(row: unknown) {
return Boolean(rowOf(row).isPublished);
}
function leagueStatusLabel(row: unknown) {
return leagueIsPublished(row) ? t('league.status.PUBLISHED') : t('league.status.UNPUBLISHED');
}
function leagueStatusTagType(row: unknown): 'success' | 'info' {
return leagueIsPublished(row) ? 'success' : 'info';
}
function leagueBetStats(row: unknown) {
return rowOf(row).betStats as
| { betCount?: number; totalStake?: string; pendingCount?: number }
| undefined;
}
function leagueBetCount(row: unknown) {
return Number(leagueBetStats(row)?.betCount ?? 0);
}
function leagueTotalStake(row: unknown) {
return formatAmount(String(leagueBetStats(row)?.totalStake ?? '0'));
}
function leaguePendingBets(row: unknown) {
return Number(leagueBetStats(row)?.pendingCount ?? 0);
}
function isLeagueExpanded(id: string) {
return expandedRowKeys.value.includes(id);
}
function openLeagueArchive(row: unknown) {
leagueArchiveId.value = leagueId(row);
leagueArchiveName.value = leagueTitle(row);
@@ -377,7 +437,9 @@ function onLeagueArchived() {
</script>
<template>
<div class="admin-list-page matches-page">
<div class="matches-shell">
<router-view v-if="isMatchChildRoute" />
<div v-else class="admin-list-page matches-page">
<div class="list-chrome">
<div class="list-chrome__row">
<div class="list-chrome__left">
@@ -417,114 +479,67 @@ function onLeagueArchived() {
<p v-if="filterStatus" class="list-hint">{{ t('match.filter.status_hint') }}</p>
</div>
<section class="list-panel">
<p class="list-hint">{{ t('match.expand_league_hint') }}</p>
<section v-loading="listLoading" class="list-panel">
<p class="list-hint">{{ t('match.open_league_hint') }}</p>
<div class="table-wrap">
<el-table
:data="leagues"
stripe
row-key="id"
:expand-row-keys="expandedRowKeys"
:row-class-name="rowClassName"
@expand-change="onExpandChange"
@row-click="onRowClick"
@row-click="openLeaguePage"
>
<el-table-column type="expand" width="40">
<el-table-column prop="displaySeq" :label="t('common.seq')" width="70" align="center" />
<el-table-column width="40" align="center" class-name="league-logo-cell">
<template #default="{ row }">
<template v-if="isLeagueExpanded(leagueId(row))">
<LeagueMatchesPanel
:league-id="leagueId(row)"
: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 :label="t('match.col.league')" width="148" show-overflow-tooltip>
<template #default="{ row }">
<div class="league-cell">
<img
v-if="rowOf(row).logoUrl"
:src="String(rowOf(row).logoUrl)"
alt=""
class="league-logo"
/>
<span class="matchup-link">{{ leagueNameZh(row) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="t('match.col.league_en')" width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="league-en">{{ leagueNameEn(row) }}</span>
<img
v-if="row.logoUrl"
:src="String(row.logoUrl)"
alt=""
class="league-logo"
/>
</template>
</el-table-column>
<el-table-column
prop="displayNameZh"
:label="t('match.col.league')"
width="108"
show-overflow-tooltip
class-name="league-name-cell"
/>
<el-table-column prop="displayNameEn" :label="t('match.col.league_en')" width="180" show-overflow-tooltip class-name="league-en-cell" />
<el-table-column :label="t('common.status')" width="88" align="center">
<template #default="{ row }">
<el-tag :type="leagueStatusTagType(row)" size="small" effect="plain">
{{ leagueStatusLabel(row) }}
<el-tag :type="row.displayStatusTagType" size="small" effect="plain">
{{ row.displayStatusLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('match.col.fixture_count')" width="88" align="center">
<template #default="{ row }">{{ leagueMatchCount(row) }}</template>
</el-table-column>
<el-table-column prop="displayMatchCount" :label="t('match.col.fixture_count')" width="88" align="center" />
<el-table-column :label="t('match.col.bet_count')" width="72" align="center">
<template #default="{ row }">
<span :class="{ 'bet-stat-active': leagueBetCount(row) > 0 }">{{ leagueBetCount(row) }}</span>
<span :class="{ 'bet-stat-active': row.displayBetCountActive }">{{ row.displayBetCount }}</span>
</template>
</el-table-column>
<el-table-column :label="t('match.col.total_stake')" width="108" align="right">
<template #default="{ row }">{{ leagueTotalStake(row) }}</template>
</el-table-column>
<el-table-column prop="displayTotalStake" :label="t('match.col.total_stake')" width="108" align="right" />
<el-table-column :label="t('match.col.pending_bets')" width="80" align="center">
<template #default="{ row }">
<el-tag v-if="leaguePendingBets(row) > 0" type="warning" size="small" effect="plain">
{{ leaguePendingBets(row) }}
<el-tag v-if="row.displayPendingBets > 0" type="warning" size="small" effect="plain">
{{ row.displayPendingBets }}
</el-tag>
<span v-else class="bet-stat-zero">0</span>
</template>
</el-table-column>
<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">
<template #header>
<div class="actions-col-header">
<span class="actions-col-header__label">{{ t('common.actions') }}</span>
</div>
</template>
<el-table-column prop="displayCode" :label="t('match.col.league_code')" width="120" show-overflow-tooltip />
<el-table-column :label="t('common.actions')" width="280" align="center" fixed="right">
<template #default="{ row }">
<div class="league-row-actions">
<div class="league-action-group">
<el-button size="small" type="primary" @click.stop="openEditLeague(row)">
{{ t('common.edit') }}
</el-button>
<el-button
v-if="!leagueIsPublished(row)"
size="small"
type="success"
:loading="publishingLeagueId === leagueId(row)"
@click.stop="toggleLeaguePublish(row)"
>
{{ t('common.publish') }}
</el-button>
<el-button
v-else
size="small"
type="warning"
:loading="publishingLeagueId === leagueId(row)"
@click.stop="toggleLeaguePublish(row)"
>
{{ t('league.btn.unpublish') }}
</el-button>
<el-button size="small" type="danger" plain @click.stop="openLeagueArchive(row)">
{{ t('common.delete') }}
</el-button>
</div>
</div>
<LeagueRowActions
:row="row"
@edit="() => openEditLeague(row)"
@create-fixture="() => openCreateFixture(row)"
@toggle-publish="() => toggleLeaguePublish(row)"
@archive="() => openLeagueArchive(row)"
/>
</template>
</el-table-column>
</el-table>
@@ -668,9 +683,22 @@ function onLeagueArchived() {
@archived="onLeagueArchived"
/>
</div>
</div>
</template>
<style scoped>
.matches-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.matches-shell > :deep(.league-matches-page) {
flex: 1;
min-height: 0;
}
.team-country-select {
width: 100%;
}
@@ -711,28 +739,17 @@ function onLeagueArchived() {
margin-bottom: 16px;
}
/* 列表表格随内容增高,滚动交给外层 table-wrap仅赛事行 */
.matches-page .table-wrap .el-table {
height: auto !important;
}
.matches-page .table-wrap :deep(.el-table__header),
.matches-page .table-wrap :deep(.el-table__body) {
width: 100% !important;
/* 联赛列表沿用 admin-list-page 内滚:表头固定、分页贴底 */
.matches-page .table-wrap :deep(.el-table__header-wrapper) {
position: sticky;
top: 0;
z-index: 2;
}
.matches-page :deep(.el-table__expanded-cell) {
padding: 0 !important;
background: #fbfaf7;
}
.list-panel :deep(.row-expandable) {
.list-panel :deep(.row-navigable) {
cursor: pointer;
}
.list-panel :deep(.row-no-expand .el-table__expand-icon) {
visibility: hidden;
pointer-events: none;
}
.matchup-link {
color: var(--green-text);
@@ -745,12 +762,6 @@ function onLeagueArchived() {
color: #aaa49a;
}
.league-cell {
display: flex;
align-items: center;
gap: 8px;
}
.league-logo {
width: 28px;
height: 28px;
@@ -758,6 +769,15 @@ function onLeagueArchived() {
flex-shrink: 0;
}
.matches-page .table-wrap :deep(.league-name-cell .cell) {
color: var(--primary-link);
font-weight: 600;
}
.matches-page .table-wrap :deep(.league-logo-cell .cell) {
padding: 0 4px;
}
.league-en {
color: var(--text-muted);
font-size: 13px;
@@ -768,35 +788,6 @@ function onLeagueArchived() {
font-weight: 700;
}
.actions-col-header {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 4px;
box-sizing: border-box;
white-space: nowrap;
}
.actions-col-header__label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
}
.actions-col-header :deep(.el-button) {
margin: 0 !important;
padding: 6px 10px !important;
height: 28px !important;
min-height: 28px !important;
font-size: 12px !important;
font-weight: 600;
border-radius: 6px;
white-space: nowrap;
}
.matches-page .table-wrap :deep(.el-table__header .el-table__cell) {
padding: 6px 0;
}
@@ -805,41 +796,6 @@ function onLeagueArchived() {
overflow: visible;
}
.league-row-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 6px 8px;
flex-wrap: wrap;
}
.league-action-group {
display: inline-flex;
flex-wrap: wrap;
gap: 4px;
padding: 2px 4px;
border-radius: 8px;
background: #fbfaf7;
border: 1px solid var(--border-soft);
}
.league-row-actions :deep(.el-button) {
margin: 0 !important;
min-width: 52px;
padding: 4px 10px !important;
font-size: 12px !important;
border-radius: 6px;
}
.league-row-actions :deep(.el-button:not(.is-disabled):not(:disabled)) {
cursor: pointer;
}
.league-row-actions :deep(.el-button.is-disabled),
.league-row-actions :deep(.el-button:disabled) {
cursor: not-allowed;
}
:deep(.logo-url-field) {
width: 100%;
}
@@ -880,14 +836,6 @@ function onLeagueArchived() {
gap: 12px;
}
.actions-col-header {
flex-wrap: wrap;
justify-content: flex-start;
}
.league-row-actions {
justify-content: flex-start;
}
}
</style>

View File

@@ -1,10 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { useRoute } from 'vue-router';
import { ref, computed, onBeforeUnmount, onDeactivated, watch } from 'vue';
import { consumeAdminListStale } from '../utils/adminListStale';
defineOptions({ name: 'AdminMatchesOutrights' });
import { useStaleListLifecycle } from '../composables/useStaleList';
import { useRoute, useRouter } from 'vue-router';
import { useAdminLocale } from '../composables/useAdminLocale';
import api from '../api';
import MatchesSubNav from '../components/MatchesSubNav.vue';
import LeagueOutrightOddsPanel from './matches/LeagueOutrightOddsPanel.vue';
import {
readMatchesListUiState,
writeMatchesListUiState,
@@ -12,17 +15,20 @@ import {
const { t } = useAdminLocale();
const route = useRoute();
const router = useRouter();
const isOutrightChildRoute = computed(() =>
/^\/matches\/outrights\/leagues\/[^/]+/.test(route.path),
);
const leagues = ref<unknown[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const keyword = ref('');
const expandedRowKeys = ref<string[]>([]);
function persistListUiState() {
writeMatchesListUiState({
expandedLeagueIds: [...expandedRowKeys.value],
page: page.value,
pageSize: pageSize.value,
filterStatus: '',
@@ -30,15 +36,11 @@ function persistListUiState() {
});
}
function applyExpandedFromSaved(savedIds: string[]) {
const allowed = new Set(leagues.value.map((row) => leagueId(row)));
expandedRowKeys.value = savedIds.filter((id) => allowed.has(id));
}
type LoadOptions = { restoreExpand?: boolean; keepExpand?: boolean };
type LoadOptions = { restore?: boolean };
async function load(options: LoadOptions = {}) {
const saved = options.restoreExpand ? readMatchesListUiState() : null;
consumeAdminListStale();
const saved = options.restore ? readMatchesListUiState() : null;
if (saved) {
page.value = saved.page;
pageSize.value = saved.pageSize;
@@ -54,29 +56,22 @@ async function load(options: LoadOptions = {}) {
});
leagues.value = data.data.items;
total.value = data.data.total;
if (options.restoreExpand && saved) {
applyExpandedFromSaved(saved.expandedLeagueIds);
} else if (!options.keepExpand) {
expandedRowKeys.value = [];
} else {
applyExpandedFromSaved(expandedRowKeys.value);
}
persistListUiState();
}
function onSearch() {
page.value = 1;
expandedRowKeys.value = [];
load();
}
async function resolveExpandFromQuery() {
async function resolveLeagueFromQuery() {
const qLeague = route.query.leagueId;
if (typeof qLeague === 'string' && qLeague.trim()) {
expandedRowKeys.value = [qLeague.trim()];
persistListUiState();
return;
router.replace({
path: `/matches/outrights/leagues/${qLeague.trim()}`,
query: route.query.title ? { title: String(route.query.title) } : undefined,
});
return true;
}
const qMatch = route.query.matchId;
if (typeof qMatch === 'string' && qMatch.trim()) {
@@ -84,46 +79,62 @@ async function resolveExpandFromQuery() {
const { data } = await api.get(`/admin/outrights/${qMatch.trim()}`);
const lid = data.data?.leagueId as string | undefined;
if (lid) {
expandedRowKeys.value = [lid];
persistListUiState();
router.replace(`/matches/outrights/leagues/${lid}`);
return true;
}
} catch {
/* ignore */
}
}
return false;
}
onMounted(async () => {
await load({ restoreExpand: true });
await resolveExpandFromQuery();
});
async function initialLoad() {
await load({ restore: true });
await resolveLeagueFromQuery();
}
const OUTRIGHT_CHILD_ROUTE = /^\/matches\/outrights\/leagues\/[^/]+/;
watch(
() => route.path,
(path, prevPath) => {
if (prevPath && OUTRIGHT_CHILD_ROUTE.test(prevPath) && path === '/matches/outrights') {
void load();
}
},
);
const { loading: listLoading, runLoad } = useStaleListLifecycle(() => leagues.value.length > 0, initialLoad);
onBeforeUnmount(persistListUiState);
onDeactivated(persistListUiState);
function onPageChange(p: number) {
page.value = p;
load({ keepExpand: true });
load();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
load({ keepExpand: true });
load();
}
function onExpandChange(_row: unknown, expanded: unknown[]) {
expandedRowKeys.value = expanded.map((r) => leagueId(r));
persistListUiState();
}
function onRowClick(row: unknown, _column: unknown, event: MouseEvent) {
if ((event.target as HTMLElement).closest('.el-table__expand-icon')) return;
function openLeaguePage(row: unknown, _column: unknown, event: Event) {
const target = event.target;
const el = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
if (!el) return;
if (el.closest('.el-button')) return;
const id = leagueId(row);
expandedRowKeys.value = expandedRowKeys.value.includes(id) ? [] : [id];
persistListUiState();
if (!id) return;
void router.push({
path: `/matches/outrights/leagues/${id}`,
query: { title: leagueTitle(row) },
});
}
function rowClassName() {
return 'row-expandable';
return 'row-navigable';
}
function rowOf(row: unknown) {
@@ -140,16 +151,23 @@ function leagueNameEn(row: unknown) {
const en = String(rowOf(row).leagueEn ?? '').trim();
return en || '—';
}
function leagueTitle(row: unknown) {
const zh = leagueNameZh(row);
if (zh !== '—') return zh;
return leagueNameEn(row);
}
function outrightTeamCount(row: unknown) {
return Number(rowOf(row).outrightTeamCount ?? 0);
}
function isLeagueExpanded(id: string) {
return expandedRowKeys.value.includes(id);
function outrightSettled(row: unknown) {
return Boolean(rowOf(row).isOutrightSettled);
}
</script>
<template>
<div class="admin-list-page matches-page">
<div class="matches-shell">
<router-view v-if="isOutrightChildRoute" />
<div v-else class="admin-list-page matches-page">
<div class="list-chrome">
<div class="list-chrome__row">
<div class="list-chrome__left">
@@ -173,28 +191,17 @@ function isLeagueExpanded(id: string) {
</div>
</div>
<section class="list-panel">
<p class="list-hint">{{ t('match.expand_outright_hint') }}</p>
<section v-loading="listLoading" class="list-panel">
<p class="list-hint">{{ t('match.open_outright_hint') }}</p>
<div class="table-wrap">
<el-table
:data="leagues"
stripe
row-key="id"
:expand-row-keys="expandedRowKeys"
:row-class-name="rowClassName"
@expand-change="onExpandChange"
@row-click="onRowClick"
@row-click="openLeaguePage"
>
<el-table-column type="expand" width="40">
<template #default="{ row }">
<LeagueOutrightOddsPanel
v-if="isLeagueExpanded(leagueId(row))"
:league-id="leagueId(row)"
@updated="load({ keepExpand: true })"
/>
</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">
@@ -213,6 +220,14 @@ function isLeagueExpanded(id: string) {
<span class="league-en">{{ leagueNameEn(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="108" align="center">
<template #default="{ row }">
<el-tag v-if="outrightSettled(row)" size="small" type="warning" effect="plain">
{{ t('league.status.OUTRIGHT_SETTLED') }}
</el-tag>
<span v-else class="status-dash"></span>
</template>
</el-table-column>
<el-table-column :label="t('outright.col.teams_total')" width="120" align="center">
<template #default="{ row }">{{ outrightTeamCount(row) }}</template>
</el-table-column>
@@ -235,9 +250,22 @@ function isLeagueExpanded(id: string) {
</div>
</section>
</div>
</div>
</template>
<style scoped>
.matches-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.matches-shell > :deep(.league-outrights-page) {
flex: 1;
min-height: 0;
}
.matches-page .table-wrap .el-table {
height: auto !important;
}
@@ -245,11 +273,7 @@ function isLeagueExpanded(id: string) {
.matches-page .table-wrap :deep(.el-table__body) {
width: 100% !important;
}
.matches-page :deep(.el-table__expanded-cell) {
padding: 0 !important;
background: #fbfaf7;
}
.list-panel :deep(.row-expandable) {
.list-panel :deep(.row-navigable) {
cursor: pointer;
}
.matchup-link {
@@ -270,4 +294,7 @@ function isLeagueExpanded(id: string) {
color: var(--text-muted);
font-size: 13px;
}
.status-dash {
color: var(--text-muted);
}
</style>

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';
@@ -39,6 +41,74 @@ const fileInputRef = ref<HTMLInputElement | null>(null);
const unusedCount = computed(() => files.value.filter((f) => !f.inUse).length);
const storageStats = ref<{
categories: Array<{ category: string; count: number; sizeBytes: number }>;
total: { count: number; sizeBytes: number };
} | null>(null);
const cleanupConfig = ref({ enabled: false, keepDays: 180 });
const manualCleanupBefore = ref('');
function getCategoryLabel(cat: string) {
if (cat === 'deposits') return t('media.deposits_on_disk');
return categoryLabel(cat);
}
async function loadStorageStats() {
try {
const res = await api.get('/admin/files/storage-stats');
storageStats.value = res.data.data;
} catch (err) {
console.error('Failed to load storage stats:', err);
}
}
async function loadCleanupConfig() {
try {
const res = await api.get('/admin/deposits/screenshot-cleanup-config');
cleanupConfig.value = res.data.data;
} catch (err) {
console.error('Failed to load cleanup config:', err);
}
}
async function saveCleanupConfig() {
try {
const res = await api.put('/admin/deposits/screenshot-cleanup-config', {
enabled: cleanupConfig.value.enabled,
keepDays: cleanupConfig.value.keepDays,
});
cleanupConfig.value = res.data.data;
ElMessage.success(t('msg.saved'));
} catch (err: any) {
ElMessage.error(err?.response?.data?.message || t('msg.save_failed'));
}
}
async function runManualCleanup() {
if (!manualCleanupBefore.value) return;
const beforeDate = manualCleanupBefore.value;
await ElMessageBox.confirm(
`${t('media.delete_confirm')} (${beforeDate} ${t('common.to')})`,
{ type: 'warning' }
);
try {
const res = await api.delete(`/admin/deposits/screenshots?before=${beforeDate}T00:00:00.000Z`);
const cleaned = res.data.data.cleaned;
const freedBytes = res.data.data.freedBytes;
const msg = t('media.cleanup_result')
.replace('{cleaned}', String(cleaned))
.replace('{size}', formatSize(freedBytes));
ElMessage.success(msg);
loadStorageStats();
loadFiles();
} catch (err: any) {
ElMessage.error(err?.response?.data?.message || t('msg.delete_failed'));
}
}
function categoryLabel(cat: string) {
const key = `media.category.${cat}` as const;
return t(key as any) || cat;
@@ -54,18 +124,19 @@ 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;
const res = await api.get('/admin/files', { params });
files.value = res.data.data.items;
total.value = res.data.data.total;
void loadStorageStats();
} catch {
ElMessage.error(t('common.loading'));
} finally {
loading.value = false;
if (!opts?.silent) loading.value = false;
}
}
@@ -74,9 +145,20 @@ watch(activeCategory, () => {
loadFiles();
});
watch(currentPage, loadFiles);
watch(currentPage, () => {
void loadFiles();
});
onMounted(loadFiles);
onMounted(() => {
void loadFiles();
void loadCleanupConfig();
});
onActivated(() => {
if (files.value.length > 0) {
void loadFiles({ silent: true });
void loadCleanupConfig();
}
});
async function confirmDelete(file: MediaFile) {
await ElMessageBox.confirm(t('media.delete_confirm'), { type: 'warning' });
@@ -190,6 +272,58 @@ async function doUpload() {
</div>
</div>
<!-- Storage Stats -->
<div class="stats-banner" v-if="storageStats">
<div v-for="item in storageStats.categories" :key="item.category" class="stat-card">
<span class="stat-label">{{ getCategoryLabel(item.category) }}</span>
<span class="stat-value">{{ item.count }} <span class="stat-unit">{{ t('common.times') }}</span></span>
<span class="stat-size">{{ formatSize(item.sizeBytes) }}</span>
</div>
<div class="stat-card total-card">
<span class="stat-label">{{ t('media.storage_stats') }}</span>
<span class="stat-value">{{ storageStats.total.count }} <span class="stat-unit">{{ t('common.times') }}</span></span>
<span class="stat-size">{{ formatSize(storageStats.total.sizeBytes) }}</span>
</div>
</div>
<!-- Cleanup Panel -->
<div class="cleanup-card">
<div class="cleanup-title">
<span>{{ t('media.screenshot_cleanup') }}</span>
</div>
<div class="cleanup-grid">
<!-- Auto Cleanup Config -->
<div class="cleanup-section">
<h4 class="section-subtitle">{{ t('media.cleanup_auto_enabled') }}</h4>
<div class="config-row">
<label class="switch-container">
<input type="checkbox" v-model="cleanupConfig.enabled" @change="saveCleanupConfig" />
<span class="switch-slider"></span>
</label>
<div class="days-input-group" v-if="cleanupConfig.enabled">
<span>{{ t('media.cleanup_keep_days') }}</span>
<input type="number" v-model.number="cleanupConfig.keepDays" min="1" class="num-input" />
<button class="btn btn-ghost btn-sm" @click="saveCleanupConfig">{{ t('common.save') }}</button>
</div>
</div>
</div>
<!-- Manual Cleanup -->
<div class="cleanup-section manual-section">
<h4 class="section-subtitle">{{ t('media.cleanup_before_date') }}</h4>
<div class="config-row">
<input type="date" v-model="manualCleanupBefore" class="date-input" />
<button class="btn btn-primary" :disabled="!manualCleanupBefore" @click="runManualCleanup">
{{ t('media.cleanup_run_now') }}
</button>
</div>
</div>
</div>
<div class="cleanup-tip">
* 仅清理已同意已拒绝的充值订单截图待处理的截图绝不会被删除清理后文件会被替换为已过期占位图
</div>
</div>
<!-- File grid -->
<div v-if="loading" class="state-center">{{ t('common.loading') }}</div>
<div v-else-if="files.length === 0" class="state-center muted">{{ t('media.no_files') }}</div>
@@ -302,6 +436,185 @@ async function doUpload() {
min-height: 0;
}
/* ── Storage Stats ── */
.stats-banner {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
flex-shrink: 0;
}
.stat-card {
background: #ffffff;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: var(--shadow);
transition: all 0.15s ease-in-out;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(56, 49, 37, 0.06);
}
.total-card {
border-color: var(--primary);
background: var(--accent-hover);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
font-weight: 550;
}
.stat-value {
font-size: 20px;
font-weight: 800;
color: var(--text);
}
.stat-unit {
font-size: 12px;
font-weight: 400;
color: var(--text-muted);
}
.stat-size {
font-size: 12px;
color: var(--text-muted);
}
/* ── Cleanup Panel ── */
.cleanup-card {
background: #ffffff;
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 14px;
flex-shrink: 0;
}
.cleanup-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 800;
color: var(--text);
border-bottom: 1px solid var(--border-soft);
padding-bottom: 8px;
}
.cleanup-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.cleanup-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-subtitle {
margin: 0;
font-size: 13px;
color: var(--text);
font-weight: 700;
}
.config-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.days-input-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-muted);
}
.num-input {
width: 75px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: #ffffff;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.num-input:focus {
border-color: var(--primary);
}
.date-input {
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #ffffff;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.date-input:focus {
border-color: var(--primary);
}
.btn-sm {
padding: 4px 10px;
font-size: 12px;
}
.cleanup-tip {
font-size: 12px;
color: var(--text-muted);
line-height: 1.4;
padding: 8px 12px;
background: var(--accent-hover);
border-radius: 6px;
border-left: 3px solid var(--primary);
}
/* ── Custom Toggle Switch ── */
.switch-container {
position: relative;
display: inline-block;
width: 44px;
height: 22px;
}
.switch-container input {
opacity: 0;
width: 0;
height: 0;
}
.switch-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #d5cfc3;
transition: .2s;
border-radius: 22px;
}
.switch-slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .2s;
border-radius: 50%;
}
input:checked + .switch-slider {
background-color: var(--primary);
}
input:checked + .switch-slider:before {
transform: translateX(22px);
}
/* ── Toolbar ── */
.toolbar {
display: flex;

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

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, defineAsyncComponent } from 'vue';
import { ref, computed, onMounted, defineAsyncComponent, watch, nextTick } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { formatApiErrorMessage, isApiErrorCode } from '@thebet365/shared';
import api from '../api';
@@ -23,6 +23,7 @@ import {
betTypeLabel,
betResultLabel,
} from '../utils/bet-labels';
import { markAdminListStale } from '../utils/adminListStale';
import { adminSelectionLabel } from '../utils/adminSelectionLabel';
import type { AdminMatchDetail } from './match-form';
import AdminSubNav from '../components/AdminSubNav.vue';
@@ -75,6 +76,12 @@ const { hasPermission } = usePermissions();
const canResettle = computed(() => hasPermission(AdminPerm.resettle));
const route = useRoute();
const router = useRouter();
function settlementReturnTo(isOutright = false) {
const q = route.query.returnTo;
if (typeof q === 'string' && q.startsWith('/')) return q;
return isOutright ? '/matches/outrights' : '/matches';
}
const STAT_FACT_LABELS = {
homeCorners: {
'zh-CN': '主队角球',
@@ -115,14 +122,14 @@ const matchStats = ref<{
function emptyMatchStats() {
return {
homeCorners: null,
awayCorners: null,
homeYellowCards: null,
awayYellowCards: null,
homeRedCards: null,
awayRedCards: null,
homeCards: null,
awayCards: null,
homeCorners: 0,
awayCorners: 0,
homeYellowCards: 0,
awayYellowCards: 0,
homeRedCards: 0,
awayRedCards: 0,
homeCards: 0,
awayCards: 0,
};
}
@@ -155,8 +162,29 @@ const winnerTeamId = ref('');
const outrightSelections = ref<
Array<{ teamId: string; teamCode: string; teamZh: string; teamEn: string }>
>([]);
interface ResettlePreviewItem {
betId: string;
betNo: string;
oldPayout: string;
newPayout: string;
delta: string;
oldStatus: string;
newStatus: string;
}
interface ResettlePreview {
batch: {
id: string;
batchNo: string;
};
affectedCount: number;
totalTopup: string;
totalClawback: string;
items: ResettlePreviewItem[];
}
const preview = ref<Record<string, unknown> | null>(null);
const resettlePreview = ref<Record<string, unknown> | null>(null);
const resettlePreview = ref<ResettlePreview | null>(null);
const resettleReason = ref('');
const statsSummary = ref<Pick<SettlementBetStats, 'summary' | 'bySelection'> | null>(null);
const betsList = ref<SettlementBetStats['bets'] | null>(null);
@@ -171,12 +199,71 @@ const betPage = ref(1);
const betPageSize = ref(10);
const previewPage = ref(1);
const previewPageSize = ref(10);
const previewDialogVisible = ref(false);
const resettleDialogVisible = ref(false);
const confirmLoading = ref(false);
const resettleConfirmLoading = ref(false);
const resettlePage = ref(1);
const resettlePageSize = ref(10);
const resettleItemsPage = computed(() => {
if (!resettlePreview.value?.items) return [];
const start = (resettlePage.value - 1) * resettlePageSize.value;
const end = start + resettlePageSize.value;
return resettlePreview.value.items.slice(start, end);
});
const isProgrammaticChange = ref(false);
interface SettlementHistoryRecord {
id: string;
batchNo: string;
htHomeScore: number | null;
htAwayScore: number | null;
ftHomeScore: number | null;
ftAwayScore: number | null;
homeCorners: number | null;
awayCorners: number | null;
homeYellowCards: number | null;
awayYellowCards: number | null;
homeRedCards: number | null;
awayRedCards: number | null;
homeCards: number | null;
awayCards: number | null;
totalBets: number;
totalPayout: string;
totalRefund: string;
confirmedAt: string | null;
isResettle: boolean;
reason: string | null;
operatorUsername: string;
}
const settlementHistory = ref<SettlementHistoryRecord[]>([]);
const historyLoading = ref(false);
const activeTab = ref<'bets' | 'history'>('bets');
async function loadSettlementHistory() {
if (!matchId.value) return;
historyLoading.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}/settlement/history`);
settlementHistory.value = data.data as SettlementHistoryRecord[];
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
historyLoading.value = false;
}
}
// 智能比分推荐已暂时关闭(后端 smart-score.solver.ts 保留,恢复时接回 UI 与 POST /settlement/smart-score
const matchId = computed(() => String(route.params.id ?? ''));
const isOutright = computed(() => match.value?.isOutright === true);
const outrightTitle = computed(() => {
const m = match.value;
if (!m) return '';
@@ -372,6 +459,29 @@ function formatTime(v: string) {
});
}
function formatScorePair(h: number | null, a: number | null) {
if (h == null || a == null) return '—';
return `${h}-${a}`;
}
function formatHistoryScore(row: SettlementHistoryRecord) {
const ht = formatScorePair(row.htHomeScore, row.htAwayScore);
const ft = formatScorePair(row.ftHomeScore, row.ftAwayScore);
if (ht === '—' && ft === '—') return '—';
return `${ht} / ${ft}`;
}
function formatHomeAwayPair(h: number | null, a: number | null) {
if (h == null && a == null) return '—';
return `${h ?? '—'} / ${a ?? '—'}`;
}
function formatHistoryCards(row: SettlementHistoryRecord) {
const { homeYellowCards: yH, awayYellowCards: yA, homeRedCards: rH, awayRedCards: rA } = row;
if (yH == null && yA == null && rH == null && rA == null) return '—';
return `Y:${yH ?? '—'}/${yA ?? '—'} R:${rH ?? '—'}/${rA ?? '—'}`;
}
function matchBetSelectionSummary(
row: SettlementBetStats['bets']['items'][number],
) {
@@ -436,6 +546,7 @@ function onBetPageSizeChange(size: number) {
async function loadMatch() {
if (!matchId.value) return;
loading.value = true;
isProgrammaticChange.value = true;
try {
const { data } = await api.get(`/admin/matches/${matchId.value}`);
const detail = data.data as AdminMatchDetail;
@@ -445,7 +556,7 @@ async function loadMatch() {
detail.status === 'SETTLED';
if (!settleable) {
ElMessage.warning(t('settlement.must_close_first'));
router.replace(detail.isOutright ? '/matches/outrights' : '/matches');
router.replace(settlementReturnTo(detail.isOutright));
return;
}
match.value = detail;
@@ -457,14 +568,14 @@ async function loadMatch() {
ftAway: detail.score.ftAway,
};
matchStats.value = {
homeCorners: detail.score.homeCorners ?? null,
awayCorners: detail.score.awayCorners ?? null,
homeYellowCards: detail.score.homeYellowCards ?? null,
awayYellowCards: detail.score.awayYellowCards ?? null,
homeRedCards: detail.score.homeRedCards ?? null,
awayRedCards: detail.score.awayRedCards ?? null,
homeCards: detail.score.homeCards ?? null,
awayCards: detail.score.awayCards ?? null,
homeCorners: detail.score.homeCorners ?? 0,
awayCorners: detail.score.awayCorners ?? 0,
homeYellowCards: detail.score.homeYellowCards ?? 0,
awayYellowCards: detail.score.awayYellowCards ?? 0,
homeRedCards: detail.score.homeRedCards ?? 0,
awayRedCards: detail.score.awayRedCards ?? 0,
homeCards: detail.score.homeCards ?? 0,
awayCards: detail.score.awayCards ?? 0,
};
winnerTeamId.value = detail.score.winnerTeamId ?? '';
} else {
@@ -495,11 +606,28 @@ async function loadMatch() {
}
betPage.value = 1;
await loadStats();
if (detail.status === 'PENDING_SETTLEMENT') {
try {
const previewRes = await api.get(`/admin/matches/${matchId.value}/settlement/preview`, {
params: { page: 1, pageSize: previewPageSize.value }
});
if (previewRes.data.data) {
preview.value = previewRes.data.data;
const itemsPage = previewRes.data.data.items as PreviewItemsPage;
previewPage.value = itemsPage.page;
previewPageSize.value = itemsPage.pageSize;
}
} catch (e) {
console.error('Failed to load active settlement preview', e);
}
}
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
await nextTick();
isProgrammaticChange.value = false;
}
}
@@ -508,19 +636,35 @@ const isSettled = computed(() => match.value?.status === 'SETTLED');
async function previewResettlement() {
const payload = buildSettlementPayload();
if (!payload) return;
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
...payload,
reason: resettleReason.value.trim() || undefined,
});
resettlePreview.value = data.data;
try {
const { data } = await api.post(`/admin/matches/${matchId.value}/resettle/preview`, {
...payload,
reason: resettleReason.value.trim() || undefined,
});
resettlePage.value = 1;
resettlePreview.value = data.data;
resettleDialogVisible.value = true;
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
}
}
async function confirmResettle() {
if (!resettlePreview.value?.batch) return;
await api.post(`/admin/resettle/${(resettlePreview.value.batch as { id: string }).id}/confirm`);
ElMessage.success(t('msg.resettle_confirmed'));
resettlePreview.value = null;
await loadMatch();
resettleConfirmLoading.value = true;
try {
await api.post(`/admin/resettle/${(resettlePreview.value.batch as { id: string }).id}/confirm`);
ElMessage.success(t('msg.resettle_confirmed'));
resettlePreview.value = null;
resettleDialogVisible.value = false;
await loadMatch();
void loadSettlementHistory();
markAdminListStale();
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
} finally {
resettleConfirmLoading.value = false;
}
}
function settlementApiError(e: unknown, fallback: string) {
@@ -582,7 +726,9 @@ async function previewSettlement() {
const itemsPage = data.data.items as PreviewItemsPage;
previewPage.value = itemsPage.page;
previewPageSize.value = itemsPage.pageSize;
previewDialogVisible.value = true;
await loadMatch();
markAdminListStale();
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
} finally {
@@ -603,14 +749,42 @@ function onPreviewPageSizeChange(size: number) {
async function confirm() {
if (!preview.value?.batch) return;
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success(t('msg.settlement_confirmed'));
preview.value = null;
await loadMatch();
confirmLoading.value = true;
try {
await api.post(`/admin/settlement/${(preview.value.batch as { id: string }).id}/confirm`);
ElMessage.success(t('msg.settlement_confirmed'));
preview.value = null;
previewDialogVisible.value = false;
await loadMatch();
void loadSettlementHistory();
markAdminListStale();
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
} finally {
confirmLoading.value = false;
}
}
function handlePreviewClick() {
if (preview.value) {
previewDialogVisible.value = true;
} else {
void previewSettlement();
}
}
watch(
[score, matchStats, winnerTeamId],
() => {
if (isProgrammaticChange.value) return;
preview.value = null;
},
{ deep: true }
);
onMounted(() => {
void loadMatch();
void loadSettlementHistory();
});
</script>
@@ -766,9 +940,9 @@ onMounted(() => {
<el-button
type="primary"
:loading="previewing"
@click="previewSettlement"
@click="handlePreviewClick"
>
{{ t('settlement.preview_btn') }}
{{ preview ? t('settlement.view_preview_btn') : t('settlement.preview_btn') }}
</el-button>
<span class="preview-hint">{{
isOutright ? t('settlement.outright.preview_hint') : t('settlement.preview_hint')
@@ -791,37 +965,94 @@ onMounted(() => {
<!-- 智能比分弹窗已关闭 Settlement.vue git 历史 -->
<el-card v-if="canResettle && resettlePreview" class="preview-card" shadow="never">
<div class="preview-title">{{ t('settlement.resettle_preview_title') }}</div>
<el-row :gutter="20">
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ resettlePreview.affectedCount }}</div>
<div class="pstat-label">{{ t('settlement.resettle_affected') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-green">{{ resettlePreview.totalTopup }}</div>
<div class="pstat-label">{{ t('settlement.resettle_topup') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-orange">{{ resettlePreview.totalClawback }}</div>
<div class="pstat-label">{{ t('settlement.resettle_clawback') }}</div>
</div>
</el-col>
</el-row>
<el-button type="warning" class="confirm-btn" @click="confirmResettle">
{{ t('settlement.resettle_confirm') }}
</el-button>
</el-card>
<!-- 重新结算预览弹窗 -->
<el-dialog
v-model="resettleDialogVisible"
:title="t('settlement.resettle_preview_title')"
width="850px"
destroy-on-close
>
<div v-if="resettlePreview">
<el-row :gutter="20">
<el-col :span="8">
<div class="pstat">
<div class="pstat-value">{{ resettlePreview.affectedCount }}</div>
<div class="pstat-label">{{ t('settlement.resettle_affected') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-green">{{ resettlePreview.totalTopup }}</div>
<div class="pstat-label">{{ t('settlement.resettle_topup') }}</div>
</div>
</el-col>
<el-col :span="8">
<div class="pstat">
<div class="pstat-value pstat-orange">{{ resettlePreview.totalClawback }}</div>
<div class="pstat-label">{{ t('settlement.resettle_clawback') }}</div>
</div>
</el-col>
</el-row>
<el-card v-if="preview" class="preview-card preview-card--compact" shadow="never">
<div class="preview-bar">
<span class="preview-bar-title">{{ t('settlement.preview_title') }}</span>
<div class="preview-metrics">
<div v-if="resettlePreview.items && resettlePreview.items.length > 0" class="preview-items-wrap" style="margin-top: 20px;">
<div class="preview-items-head" style="margin-bottom: 10px;">
<span class="preview-items-title" style="font-size: 14px; font-weight: 600; color: var(--text);">
{{ t('settlement.resettle_affected_list') }} ({{ resettlePreview.items.length }})
</span>
</div>
<el-table :data="resettleItemsPage" size="small" stripe class="preview-items-table" style="max-height: 400px; overflow-y: auto;">
<el-table-column type="index" :index="(i: number) => (resettlePage - 1) * resettlePageSize + 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('settlement.resettle_col.old_result')" min-width="160">
<template #default="{ row }">
<el-tag size="small" :type="betStatusTagType(row.oldStatus)" style="margin-right: 6px;">{{ betStatusLabel(row.oldStatus) }}</el-tag>
<span class="old-payout" style="color: var(--text-muted); font-size: 11px;">{{ formatAmount(row.oldPayout) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('settlement.resettle_col.new_result')" min-width="160">
<template #default="{ row }">
<el-tag size="small" :type="betStatusTagType(row.newStatus)" style="margin-right: 6px;">{{ betStatusLabel(row.newStatus) }}</el-tag>
<span class="new-payout" style="color: var(--text); font-size: 11px;">{{ formatAmount(row.newPayout) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('settlement.resettle_col.adjust')" width="120" align="right">
<template #default="{ row }">
<span :class="Number(row.delta) > 0 ? 'pstat-green' : Number(row.delta) < 0 ? 'pstat-orange' : ''" style="font-weight: bold;">
{{ Number(row.delta) > 0 ? '+' : '' }}{{ formatAmount(row.delta) }}
</span>
</template>
</el-table-column>
</el-table>
<el-pagination
class="preview-pager bet-pager"
size="small"
background
layout="total, sizes, prev, pager, next"
:total="resettlePreview.items.length"
v-model:current-page="resettlePage"
v-model:page-size="resettlePageSize"
:page-sizes="[10, 20, 50, 100]"
style="margin-top: 12px; justify-content: flex-end;"
/>
</div>
</div>
<template #footer>
<el-button @click="resettleDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="warning" :loading="resettleConfirmLoading" @click="confirmResettle">
{{ t('settlement.resettle_confirm') }}
</el-button>
</template>
</el-dialog>
<!-- 结算预览弹窗 -->
<el-dialog
v-model="previewDialogVisible"
:title="t('settlement.preview_title')"
width="850px"
destroy-on-close
>
<div v-if="preview">
<div class="preview-metrics" style="margin-bottom: 16px;">
<div class="preview-metric">
<span class="preview-metric-value">{{ preview.pendingBetCount ?? preview.singleBetCount }}</span>
<span class="preview-metric-label">{{ t('settlement.preview_pending_bets') }}</span>
@@ -839,145 +1070,201 @@ onMounted(() => {
<span class="preview-metric-label">{{ t('settlement.refund_amount') }}</span>
</div>
</div>
<el-button type="success" size="small" @click="confirm">
<p v-if="previewZeroHint" class="preview-zero-hint" style="margin-bottom: 16px;">{{ previewZeroHint }}</p>
<div v-if="previewItemsPage.total > 0" class="preview-items-wrap">
<div class="preview-items-head">
<span class="preview-items-title">
{{ t('settlement.preview_items_title', { n: previewItemsPage.total }) }}
</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>
</el-table-column>
<el-table-column :label="t('settlement.preview_col.result')" width="108">
<template #default="{ row }">{{ previewResultLabel(row.result) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.est_payout')" width="92" align="right">
<template #default="{ row }">{{ formatAmount(row.payout) }}</template>
</el-table-column>
<el-table-column :label="t('user.field.remark')" min-width="160" show-overflow-tooltip prop="note" />
</el-table>
<el-pagination
v-if="previewItemsPage.total > 0"
class="preview-pager bet-pager"
size="small"
background
layout="total, sizes, prev, pager, next"
:total="previewItemsPage.total"
:current-page="previewItemsPage.page"
:page-size="previewItemsPage.pageSize"
:page-sizes="[10, 20, 50]"
@current-change="onPreviewPageChange"
@size-change="onPreviewPageSizeChange"
/>
</div>
</div>
<template #footer>
<el-button @click="previewDialogVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="success" :loading="confirmLoading" @click="confirm">
{{ t('settlement.confirm_btn') }}
</el-button>
</div>
<p v-if="previewZeroHint" class="preview-zero-hint">{{ previewZeroHint }}</p>
<div v-if="previewItemsPage.total > 0" class="preview-items-wrap">
<div class="preview-items-head">
<span class="preview-items-title">
{{ t('settlement.preview_items_title', { n: previewItemsPage.total }) }}
</span>
</div>
<el-table :data="previewItemsPage.items" size="small" stripe class="preview-items-table">
<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>
</el-table-column>
<el-table-column :label="t('settlement.preview_col.result')" width="108">
<template #default="{ row }">{{ previewResultLabel(row.result) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.est_payout')" width="92" align="right">
<template #default="{ row }">{{ formatAmount(row.payout) }}</template>
</el-table-column>
<el-table-column :label="t('user.field.remark')" min-width="160" show-overflow-tooltip prop="note" />
</el-table>
<el-pagination
v-if="previewItemsPage.total > 0"
class="preview-pager bet-pager"
size="small"
background
layout="total, sizes, prev, pager, next"
:total="previewItemsPage.total"
:current-page="previewItemsPage.page"
:page-size="previewItemsPage.pageSize"
:page-sizes="[10, 20, 50]"
@current-change="onPreviewPageChange"
@size-change="onPreviewPageSizeChange"
/>
</div>
</el-card>
</template>
</el-dialog>
<el-card v-loading="statsLoading" class="stats-card" shadow="never">
<div v-if="stats" class="stats-body">
<div class="stats-charts">
<div v-if="betTypeChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="betTypeChartOption" autoresize />
</div>
<div v-if="statusChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="statusChartOption" autoresize />
</div>
<div v-if="selectionStakeChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="selectionStakeChartOption" autoresize />
</div>
</div>
<div class="stats-tables">
<div class="stats-table-block">
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
<el-table
v-if="stats.bySelection.length"
:data="stats.bySelection"
size="small"
stripe
class="stats-table"
height="100%"
>
<el-table-column :label="t('settlement.col.market')" min-width="120">
<template #default="{ row }">
{{ marketLabel(row.marketType) }}
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
</template>
</el-table-column>
<el-table-column :label="t('settlement.col.selection')" min-width="120">
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.legs')" width="72" align="center" prop="legCount" />
<el-table-column :label="t('settlement.col.single_stake')" width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.parlay_legs')" width="88" align="center" prop="parlayLegCount" />
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
</div>
<div class="stats-table-block stats-table-block--bets">
<div class="subsection-title">
{{ t('settlement.bet_list') }} ({{ stats.bets.total }})
<span class="subsection-hint">{{ t('settlement.bet_list_hint') }}</span>
<el-card v-loading="statsLoading && activeTab === 'bets'" class="stats-card" shadow="never">
<el-tabs v-model="activeTab" class="settlement-tabs">
<el-tab-pane name="bets" :label="t('settlement.history_tab_bets')">
<div v-if="stats" class="stats-body">
<div class="stats-charts">
<div v-if="betTypeChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="betTypeChartOption" autoresize />
</div>
<div v-if="statusChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="statusChartOption" autoresize />
</div>
<div v-if="selectionStakeChartOption" class="mini-chart">
<VChart class="mini-chart-canvas" :option="selectionStakeChartOption" autoresize />
</div>
</div>
<div v-loading="betsLoading" class="table-wrap">
<el-table
v-if="stats.bets.items.length"
:data="stats.bets.items"
size="small"
stripe
class="stats-table"
>
<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">
<template #default="{ row }">
{{ betTypeLabel(row.betType) }}
<span v-if="row.legCountOnMatch > 1" class="leg-badge">×{{ row.legCountOnMatch }}</span>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.content')" min-width="200">
<template #default="{ row }">
<span class="bet-content-cell">{{ matchBetSelectionSummary(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.stake')" width="88" align="right">
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.placed_at')" width="120">
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
</el-table-column>
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
<div class="stats-tables">
<div class="stats-table-block">
<div class="subsection-title">{{ t('settlement.stats_by_market') }}</div>
<div class="table-wrap">
<el-table
v-if="stats.bySelection.length"
:data="stats.bySelection"
size="small"
stripe
class="stats-table"
>
<el-table-column :label="t('settlement.col.market')" min-width="120">
<template #default="{ row }">
{{ marketLabel(row.marketType) }}
<span v-if="row.period" class="period-tag">{{ row.period }}</span>
</template>
</el-table-column>
<el-table-column :label="t('settlement.col.selection')" min-width="120">
<template #default="{ row }">{{ selectionDisplay(row) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.legs')" width="72" align="center" prop="legCount" />
<el-table-column :label="t('settlement.col.single_stake')" width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.singleStake) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.col.parlay_legs')" width="88" align="center" prop="parlayLegCount" />
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
</div>
</div>
<div class="stats-table-block stats-table-block--bets">
<div class="subsection-title">
{{ t('settlement.bet_list') }} ({{ stats.bets.total }})
<span class="subsection-hint">{{ t('settlement.bet_list_hint') }}</span>
</div>
<div v-loading="betsLoading" class="table-wrap">
<el-table
v-if="stats.bets.items.length"
:data="stats.bets.items"
size="small"
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">
<template #default="{ row }">
{{ betTypeLabel(row.betType) }}
<span v-if="row.legCountOnMatch > 1" class="leg-badge">×{{ row.legCountOnMatch }}</span>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.content')" min-width="200">
<template #default="{ row }">
<span class="bet-content-cell">{{ matchBetSelectionSummary(row) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.stake')" width="88" align="right">
<template #default="{ row }">{{ formatAmount(row.stake) }}</template>
</el-table-column>
<el-table-column :label="t('common.status')" width="88">
<template #default="{ row }">
<el-tag size="small" :type="betStatusTagType(row.status)">{{ betStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('bet.col.placed_at')" width="120">
<template #default="{ row }">{{ formatTime(row.placedAt) }}</template>
</el-table-column>
</el-table>
<p v-else class="empty-hint">{{ t('settlement.no_bets') }}</p>
</div>
<el-pagination
v-if="stats.bets.total > 0"
class="bet-pager"
size="small"
background
layout="total, sizes, prev, pager, next"
:total="stats.bets.total"
:current-page="stats.bets.page"
:page-size="stats.bets.pageSize"
:page-sizes="[10, 20, 50]"
@current-change="onBetPageChange"
@size-change="onBetPageSizeChange"
/>
</div>
</div>
<el-pagination
v-if="stats.bets.total > 0"
class="bet-pager"
size="small"
background
layout="total, sizes, prev, pager, next"
:total="stats.bets.total"
:current-page="stats.bets.page"
:page-size="stats.bets.pageSize"
:page-sizes="[10, 20, 50]"
@current-change="onBetPageChange"
@size-change="onBetPageSizeChange"
/>
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane name="history" :label="t('settlement.history_tab')">
<div v-loading="historyLoading" class="history-body">
<div class="table-wrap">
<el-table
:data="settlementHistory"
size="small"
stripe
class="stats-table history-table"
:empty-text="t('settlement.history.no_records')"
>
<el-table-column prop="batchNo" :label="t('settlement.history.col.batch_no')" min-width="120" show-overflow-tooltip />
<el-table-column :label="t('settlement.history.col.type')" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.isResettle ? 'warning' : 'success'">
{{ row.isResettle ? t('settlement.history.type.resettle') : t('settlement.history.type.initial') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="t('settlement.history.col.score')" min-width="120">
<template #default="{ row }">{{ formatHistoryScore(row) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.history.col.corners')" width="100">
<template #default="{ row }">{{ formatHomeAwayPair(row.homeCorners, row.awayCorners) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.history.col.cards')" min-width="120">
<template #default="{ row }">{{ formatHistoryCards(row) }}</template>
</el-table-column>
<el-table-column prop="totalBets" :label="t('settlement.history.col.total_bets')" width="96" align="right" />
<el-table-column :label="t('settlement.history.col.total_payout')" width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.totalPayout) }}</template>
</el-table-column>
<el-table-column :label="t('settlement.history.col.total_refund')" width="100" align="right">
<template #default="{ row }">{{ formatAmount(row.totalRefund) }}</template>
</el-table-column>
<el-table-column prop="operatorUsername" :label="t('settlement.history.col.operator')" width="96" show-overflow-tooltip />
<el-table-column :label="t('settlement.history.col.time')" width="120">
<template #default="{ row }">{{ row.confirmedAt ? formatTime(row.confirmedAt) : '—' }}</template>
</el-table-column>
<el-table-column :label="t('settlement.history.col.reason')" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.reason || '—' }}</template>
</el-table-column>
</el-table>
</div>
</div>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
@@ -1029,6 +1316,72 @@ onMounted(() => {
overflow: hidden;
}
.settlement-tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.settlement-tabs :deep(.el-tabs__header) {
margin-bottom: 8px;
flex-shrink: 0;
}
.settlement-tabs :deep(.el-tabs__nav-wrap) {
margin-bottom: 0;
}
.settlement-tabs :deep(.el-tabs__item) {
height: 34px;
line-height: 34px;
font-size: 13px;
font-weight: 500;
color: #888;
padding: 0 14px;
}
.settlement-tabs :deep(.el-tabs__item.is-active) {
color: #d4fde5;
font-weight: 700;
}
.settlement-tabs :deep(.el-tabs__active-bar) {
background-color: var(--gold-bright);
height: 2px;
}
.settlement-tabs :deep(.el-tabs__content) {
flex: 1;
min-height: 0;
overflow: hidden;
padding: 0;
}
.settlement-tabs :deep(.el-tab-pane) {
height: 100%;
}
.history-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
.history-body .table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
}
.history-table {
width: 100%;
}
.settle-score-row {
display: flex;
align-items: flex-end;
@@ -1223,7 +1576,7 @@ onMounted(() => {
overflow: hidden;
}
.stats-table-block--bets .table-wrap {
.stats-table-block .table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
@@ -1485,7 +1838,7 @@ onMounted(() => {
color: var(--text-muted);
}
.stats-table-block--bets .table-wrap {
.stats-table-block .table-wrap {
border-color: var(--border);
background: #ffffff;
}

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

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { ref, computed, inject, onMounted, onUnmounted, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useAdminLocale } from '../../composables/useAdminLocale';
import {
agentDirectPlayersReloadKey,
agentPlayerActionsKey,
} from '../../composables/agent-direct-players-context';
import api from '../../api';
import AdminSubNav from '../../components/AdminSubNav.vue';
import AdminTableWrap from '../../components/AdminTableWrap.vue';
import AdminTableEmpty from '../../components/AdminTableEmpty.vue';
import AdminPlayerStatusCell from '../../components/AdminPlayerStatusCell.vue';
import AdminPlayerRowActions from '../../components/AdminPlayerRowActions.vue';
import { formatAmount, formatAmountFull } from '../../utils/format-amount';
import type { PlayerRow } from '../user-form';
defineOptions({ name: 'AdminAgentDirectPlayersView' });
const route = useRoute();
const { t } = useAdminLocale();
const actions = inject(agentPlayerActionsKey);
const reloadRef = inject(agentDirectPlayersReloadKey);
const agentId = computed(() => String(route.params.agentId ?? ''));
const agentUsername = computed(() => {
const name = String(route.query.username ?? '').trim();
return name || `#${agentId.value}`;
});
const players = ref<PlayerRow[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(20);
const keyword = ref('');
const filterStatus = ref('');
const loading = ref(false);
const pageTitle = computed(() =>
t('agent.direct_players_title', { name: agentUsername.value }),
);
async function loadPlayers() {
if (!agentId.value) return;
loading.value = true;
try {
const { data } = await api.get('/admin/users', {
params: {
parentId: agentId.value,
page: page.value,
pageSize: pageSize.value,
keyword: keyword.value.trim() || undefined,
status: filterStatus.value || undefined,
},
});
players.value = (data.data.items ?? []) as PlayerRow[];
total.value = data.data.total ?? 0;
} finally {
loading.value = false;
}
}
function onSearch() {
page.value = 1;
void loadPlayers();
}
function onPageChange(p: number) {
page.value = p;
void loadPlayers();
}
function onSizeChange(size: number) {
pageSize.value = size;
page.value = 1;
void loadPlayers();
}
watch(agentId, () => {
page.value = 1;
void loadPlayers();
});
onMounted(() => {
if (reloadRef) reloadRef.value = () => void loadPlayers();
void loadPlayers();
});
onUnmounted(() => {
if (reloadRef) reloadRef.value = null;
});
</script>
<template>
<div class="admin-list-page agent-direct-players-page">
<AdminSubNav :title="pageTitle" :subtitle="t('agent.direct_players')" />
<section class="list-panel player-list-panel">
<div class="list-panel-toolbar">
<el-form inline class="list-chrome__grow">
<el-form-item :label="t('common.keyword')">
<el-input
v-model="keyword"
:placeholder="t('user.filter.username_ph')"
clearable
style="width: 180px"
@keyup.enter="onSearch"
/>
</el-form-item>
<el-form-item :label="t('common.status')">
<el-select v-model="filterStatus" :placeholder="t('common.all')" clearable style="width: 120px">
<el-option :label="t('user.status.ACTIVE')" value="ACTIVE" />
<el-option :label="t('user.status.SUSPENDED')" value="SUSPENDED" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="onSearch">{{ t('common.search') }}</el-button>
</el-form-item>
</el-form>
<div v-if="actions?.canCreatePlayer" class="list-chrome__actions">
<el-button type="primary" @click="actions?.openCreatePlayer(agentId)">
{{ t('user.create_btn') }}
</el-button>
</div>
</div>
<AdminTableWrap>
<el-table v-loading="loading" :data="players" stripe class="inner-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="username" :label="t('user.col.username')" min-width="120" />
<el-table-column :label="t('common.status')" min-width="128">
<template #default="{ row }">
<AdminPlayerStatusCell :status="row.status" :is-online="row.isOnline" />
</template>
</el-table-column>
<el-table-column :label="t('user.col.invite_code')" min-width="96" show-overflow-tooltip>
<template #default="{ row }">
<code v-if="row.inviteCode" class="invite-code-cell">{{ row.inviteCode }}</code>
<span v-else></span>
</template>
</el-table-column>
<el-table-column :label="t('user.col.balance')" min-width="120" align="right">
<template #default="{ row }">
<el-tooltip
:content="`${formatAmountFull(row.availableBalance)} / ${formatAmountFull(row.frozenBalance)}`"
placement="top"
>
<span class="amount-compact">
{{ formatAmount(row.availableBalance) }} / {{ formatAmount(row.frozenBalance) }}
</span>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="betCount" :label="t('user.col.bets')" width="56" align="center" />
<el-table-column :label="t('user.col.stake_payout')" min-width="100" align="right">
<template #default="{ row }">
<span class="amount-compact">
{{ formatAmount(row.totalStake) }} / {{ formatAmount(row.totalReturn) }}
</span>
</template>
</el-table-column>
<el-table-column v-if="actions" :label="t('common.actions')" min-width="320" align="center">
<template #default="{ row }">
<AdminPlayerRowActions
v-bind="actions.playerActionFlags"
:row="row"
@detail="actions.openDetailPlayer(row.id)"
@ledger="actions.openPlayerWalletLedger(row.id, row.username)"
@edit="actions.openEditPlayer(row.id)"
@deposit="actions.openTransfer('deposit', row)"
@withdraw="actions.openTransfer('withdraw', row)"
@freeze="actions.toggleFreezePlayer(row)"
@delete="actions.deletePlayer(row)"
/>
</template>
</el-table-column>
</el-table>
</AdminTableWrap>
<div class="pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
background
@current-change="onPageChange"
@size-change="onSizeChange"
/>
</div>
</section>
</div>
</template>
<style scoped>
.agent-direct-players-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.agent-direct-players-page :deep(.admin-subnav) {
margin-bottom: 14px;
}
.agent-direct-players-page .list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.agent-direct-players-page .player-list-panel :deep(.admin-table-wrap) {
flex: 1;
min-height: 0;
}
.agent-direct-players-page .inner-table {
height: 100%;
}
</style>

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

@@ -0,0 +1,439 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { usePermissions } from '../../composables/usePermissions';
import { AdminPerm } from '../../constants/permissions';
import api from '../../api';
import { clearStaffSession } from '../../stores/auth';
import AdminSubNav from '../../components/AdminSubNav.vue';
import RatePercentInput from '../../components/RatePercentInput.vue';
import { percentToDecimalRate, decimalRateToPercent } from '../../utils/rate-percent';
defineOptions({ name: 'AdminGlobalSettingsView' });
const { t } = useAdminLocale();
const router = useRouter();
const { hasPermission } = usePermissions();
const canManageSettings = hasPermission(AdminPerm.settings);
const playerSettings = ref({ allowPasswordChange: true, allowUsernameChange: false });
const bettingLimits = ref({
minStake: 1,
maxStakeSingle: 50000,
maxStakeParlay: 20000,
maxPayoutSingle: 500000,
maxPayoutParlay: 1000000,
dailyStakeLimit: 200000,
});
const hierarchySettings = ref({ maxAgentLevel: 0, defaultSubAgentCreditRatio: 50 });
const agentSuspendSettings = ref({
suspendFreezeDirectPlayers: false,
suspendBlockPlayerLogin: false,
});
const platformDirectRate = ref(0);
const adminInviteRate = ref(0);
const resetAllowed = ref(false);
const resetConfirmPhrase = ref('');
const settingsSaving = ref(false);
const limitsSaving = ref(false);
const hierarchySaving = ref(false);
const suspendSaving = ref(false);
const platformDirectSaving = ref(false);
const resetLoading = ref(false);
const loading = ref(false);
async function loadSettings() {
loading.value = true;
try {
const { data } = await api.get('/admin/users/page-init');
const payload = data.data as {
playerSettings?: typeof playerSettings.value;
bettingLimits?: typeof bettingLimits.value;
hierarchySettings?: { maxAgentLevel: number; defaultSubAgentCreditRatio?: number };
platformDirect?: { platformDirectRate?: number | string; adminInviteRate?: number | string };
};
if (payload.playerSettings) playerSettings.value = payload.playerSettings;
if (payload.bettingLimits) bettingLimits.value = payload.bettingLimits;
if (payload.hierarchySettings) {
hierarchySettings.value = {
maxAgentLevel: payload.hierarchySettings.maxAgentLevel ?? 0,
defaultSubAgentCreditRatio: payload.hierarchySettings.defaultSubAgentCreditRatio ?? 50,
};
}
if (payload.platformDirect) {
platformDirectRate.value = decimalRateToPercent(payload.platformDirect.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(
payload.platformDirect.adminInviteRate ?? payload.platformDirect.platformDirectRate ?? 0,
);
}
} catch {
/* keep defaults */
} finally {
loading.value = false;
}
}
async function loadResetDatabaseStatus() {
try {
const { data } = await api.get('/admin/system/reset-database');
resetAllowed.value = !!data.data?.allowed;
} catch {
resetAllowed.value = false;
}
}
async function savePlayerSettings() {
settingsSaving.value = true;
try {
const { data } = await api.put('/admin/users/settings/account', playerSettings.value);
playerSettings.value = data.data;
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'));
} finally {
settingsSaving.value = false;
}
}
async function loadAgentSuspendSettings() {
try {
const { data } = await api.get('/admin/agents/settings/suspend');
agentSuspendSettings.value = {
suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers),
suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin),
};
} catch {
/* keep defaults */
}
}
async function saveHierarchySettings() {
hierarchySaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/hierarchy', hierarchySettings.value);
hierarchySettings.value = {
maxAgentLevel: data.data?.maxAgentLevel ?? hierarchySettings.value.maxAgentLevel,
defaultSubAgentCreditRatio:
data.data?.defaultSubAgentCreditRatio ?? hierarchySettings.value.defaultSubAgentCreditRatio,
};
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'));
} finally {
hierarchySaving.value = false;
}
}
async function saveAgentSuspendSettings() {
suspendSaving.value = true;
try {
const { data } = await api.put('/admin/agents/settings/suspend', agentSuspendSettings.value);
agentSuspendSettings.value = {
suspendFreezeDirectPlayers: Boolean(data.data?.suspendFreezeDirectPlayers),
suspendBlockPlayerLogin: Boolean(data.data?.suspendBlockPlayerLogin),
};
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'));
} finally {
suspendSaving.value = false;
}
}
async function savePlatformDirectSettings() {
platformDirectSaving.value = true;
try {
const { data } = await api.put('/admin/settings/cashback/platform-direct', {
platformDirectRate: percentToDecimalRate(platformDirectRate.value),
adminInviteRate: percentToDecimalRate(adminInviteRate.value),
});
const payload = data.data as { platformDirectRate?: number | string; adminInviteRate?: number | string };
platformDirectRate.value = decimalRateToPercent(payload?.platformDirectRate ?? 0);
adminInviteRate.value = decimalRateToPercent(payload?.adminInviteRate ?? payload?.platformDirectRate ?? 0);
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'));
} finally {
platformDirectSaving.value = false;
}
}
async function saveBettingLimits() {
limitsSaving.value = true;
try {
const { data } = await api.put('/admin/settings/betting-limits', bettingLimits.value);
bettingLimits.value = data.data;
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'));
} finally {
limitsSaving.value = false;
}
}
async function resetDatabase() {
if (resetConfirmPhrase.value !== 'RESET') {
ElMessage.warning(t('user.reset_database_confirm_label'));
return;
}
try {
await ElMessageBox.confirm(t('user.reset_database_hint'), t('user.reset_database'), {
type: 'warning',
confirmButtonText: t('user.reset_database_btn'),
cancelButtonText: t('common.cancel'),
});
} catch {
return;
}
resetLoading.value = true;
try {
const { data } = await api.post('/admin/system/reset-database', { confirmPhrase: 'RESET' });
const accounts: string[] = data.data?.demoAccounts ?? [];
ElMessage.success({
message: `${t('user.reset_database_success')}\n${t('user.reset_database_accounts')}: ${accounts.join(' · ')}`,
duration: 8000,
});
clearStaffSession();
await router.push('/login');
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
} finally {
resetLoading.value = false;
}
}
onMounted(() => {
if (!canManageSettings) {
void router.replace('/users');
return;
}
void Promise.all([loadSettings(), loadAgentSuspendSettings(), loadResetDatabaseStatus()]);
});
</script>
<template>
<div v-loading="loading" class="admin-list-page global-settings-page">
<AdminSubNav :title="t('user.page_settings')" />
<section class="global-settings-panel">
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.global_settings') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('user.field.allow_password_change')">
<el-switch v-model="playerSettings.allowPasswordChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
<el-form-item :label="t('user.field.allow_username_change')">
<el-switch v-model="playerSettings.allowUsernameChange" :loading="settingsSaving" @change="savePlayerSettings" />
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('agent.hierarchy.settings_title') }}</p>
<p class="list-settings-hint">{{ t('agent.hierarchy.settings_hint') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('agent.hierarchy.max_level')">
<el-input-number
v-model="hierarchySettings.maxAgentLevel"
:min="0"
:step="1"
controls-position="right"
:disabled="hierarchySaving"
/>
</el-form-item>
<el-form-item :label="t('agent.hierarchy.default_sub_credit_ratio')">
<el-input-number
v-model="hierarchySettings.defaultSubAgentCreditRatio"
:min="1"
:max="100"
:step="1"
controls-position="right"
:disabled="hierarchySaving"
/>
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('agent.hierarchy.default_sub_credit_ratio_hint') }}</p>
<el-form-item>
<el-button type="primary" :loading="hierarchySaving" @click="saveHierarchySettings">
{{ t('common.save') }}
</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('agent.suspend.settings_title') }}</p>
<p class="list-settings-hint">{{ t('agent.suspend.settings_hint') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('agent.freeze.opt_freeze_direct_players')">
<el-switch
v-model="agentSuspendSettings.suspendFreezeDirectPlayers"
:loading="suspendSaving"
@change="saveAgentSuspendSettings"
/>
</el-form-item>
<el-form-item :label="t('agent.freeze.opt_block_player_login')">
<el-switch
v-model="agentSuspendSettings.suspendBlockPlayerLogin"
:loading="suspendSaving"
@change="saveAgentSuspendSettings"
/>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('cashback.settings_title') }}</p>
<el-form inline size="small" class="settings-form">
<el-form-item :label="t('cashback.platform_direct_default_rate')">
<RatePercentInput v-model="platformDirectRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.platform_direct_default_hint') }}</p>
<el-form-item :label="t('cashback.admin_invite_default_rate')">
<RatePercentInput v-model="adminInviteRate" />
</el-form-item>
<p class="list-settings-hint block-hint">{{ t('cashback.admin_invite_default_hint') }}</p>
<el-form-item>
<el-button type="primary" :loading="platformDirectSaving" @click="savePlatformDirectSettings">
{{ t('common.save') }}
</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block">
<p class="list-settings-title">{{ t('user.betting_limits') }}</p>
<el-form inline size="small" class="settings-form limits-form">
<el-form-item :label="t('user.limit.min_stake')">
<el-input-number v-model="bettingLimits.minStake" :min="0" :step="1" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_single')">
<el-input-number v-model="bettingLimits.maxStakeSingle" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_stake_parlay')">
<el-input-number v-model="bettingLimits.maxStakeParlay" :min="0" :step="100" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_single')">
<el-input-number v-model="bettingLimits.maxPayoutSingle" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.max_payout_parlay')">
<el-input-number v-model="bettingLimits.maxPayoutParlay" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item :label="t('user.limit.daily_stake')">
<el-input-number v-model="bettingLimits.dailyStakeLimit" :min="0" :step="1000" controls-position="right" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="limitsSaving" @click="saveBettingLimits">
{{ t('common.save') }}
</el-button>
</el-form-item>
</el-form>
</div>
<div class="list-settings-block list-settings-block--danger">
<p class="list-settings-title">{{ t('user.reset_database') }}</p>
<p class="list-settings-hint">{{ t('user.reset_database_hint') }}</p>
<el-alert
v-if="!resetAllowed"
type="warning"
:closable="false"
show-icon
class="reset-db-alert"
:title="t('user.reset_database_disabled_prod')"
/>
<el-form inline size="small" class="settings-form reset-db-form">
<el-form-item :label="t('user.reset_database_confirm_label')">
<el-input
v-model="resetConfirmPhrase"
:placeholder="t('user.reset_database_confirm_ph')"
style="width: 160px"
:disabled="!resetAllowed"
autocomplete="off"
/>
</el-form-item>
<el-form-item>
<el-button
type="danger"
plain
:loading="resetLoading"
:disabled="!resetAllowed || resetConfirmPhrase !== 'RESET'"
@click="resetDatabase"
>
{{ t('user.reset_database_btn') }}
</el-button>
</el-form-item>
</el-form>
</div>
</section>
</div>
</template>
<style scoped>
.global-settings-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.global-settings-page :deep(.admin-subnav) {
margin-bottom: 10px;
}
.global-settings-panel {
flex: 1;
min-height: 0;
overflow: auto;
padding: 12px 14px 16px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-card);
}
.list-settings-block + .list-settings-block {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-soft);
}
.list-settings-title {
font-size: 13px;
font-weight: 600;
color: var(--text);
margin: 0 0 8px;
}
.list-settings-hint {
font-size: 12px;
color: var(--text-muted);
margin: 0 0 10px;
line-height: 1.5;
}
.block-hint {
width: 100%;
margin-top: -4px;
}
.list-settings-block--danger {
border-top: 1px dashed var(--danger-border);
}
.reset-db-alert {
margin-bottom: 10px;
}
.limits-form {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
}
</style>

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

@@ -0,0 +1,277 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import { resolveFormError } from '../../i18n/form-validation';
import api from '../../api';
import AdminSubNav from '../../components/AdminSubNav.vue';
import CountryFlagSelect from '../../components/outright/CountryFlagSelect.vue';
import LogoUrlField from '../../components/LogoUrlField.vue';
import LeagueMatchesPanel from './LeagueMatchesPanel.vue';
import { getBuiltinCountry } from '../../data/builtinCountries';
import {
emptyMatchForm,
buildPlatformPayload,
fillBuiltinTeam,
clearBuiltinTeam,
type MatchCreateForm,
} from '../match-form';
defineOptions({ name: 'AdminLeagueMatches' });
const route = useRoute();
const { t } = useAdminLocale();
const leagueId = computed(() => String(route.params.leagueId ?? ''));
const filterStatus = computed(() => String(route.query.status ?? ''));
const filterKeyword = computed(() => String(route.query.keyword ?? ''));
const leagueTitle = computed(() => {
const title = String(route.query.title ?? '').trim();
return title || `#${leagueId.value}`;
});
const panelRef = ref<{ reload: () => void } | null>(null);
const createVisible = ref(false);
const createLoading = ref(false);
const isOutrightSettled = ref(false);
const form = ref<MatchCreateForm>(emptyMatchForm());
function onLeagueMeta(meta: { isOutrightSettled: boolean }) {
isOutrightSettled.value = meta.isOutrightSettled;
}
function openCreateFixture() {
if (isOutrightSettled.value) return;
form.value = emptyMatchForm();
form.value.leagueId = leagueId.value;
createVisible.value = true;
}
function onTeamCodeChange(side: 'home' | 'away', code: string) {
if (!code?.trim()) {
clearBuiltinTeam(form.value, side);
return;
}
const country = getBuiltinCountry(code);
if (country) fillBuiltinTeam(form.value, side, country);
}
async function submitCreate() {
let payload: ReturnType<typeof buildPlatformPayload>;
try {
payload = buildPlatformPayload(form.value);
} catch (e) {
ElMessage.warning(resolveFormError(e, t));
return;
}
createLoading.value = true;
try {
await api.post('/admin/matches', payload);
ElMessage.success(t('msg.match_created_draft'));
createVisible.value = false;
panelRef.value?.reload();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.create_failed'));
} finally {
createLoading.value = false;
}
}
watch(leagueId, () => {
form.value.leagueId = leagueId.value;
}, { immediate: true });
</script>
<template>
<div class="admin-list-page league-matches-page">
<AdminSubNav
:title="leagueTitle"
:subtitle="t('match.league_fixtures_subtitle')"
>
<template #extra>
<el-button v-if="!isOutrightSettled" type="primary" @click="openCreateFixture">
{{ t('match.create_fixture_btn') }}
</el-button>
<span v-else class="settled-hint">{{ t('league.hint.outright_settled_no_fixture') }}</span>
</template>
</AdminSubNav>
<section class="list-panel">
<LeagueMatchesPanel
ref="panelRef"
:league-id="leagueId"
:filter-status="filterStatus"
:keyword="filterKeyword"
@league-meta="onLeagueMeta"
/>
</section>
<el-dialog
v-model="createVisible"
:title="t('match.dialog.create_fixture')"
width="860px"
destroy-on-close
>
<el-form label-width="96px">
<el-form-item :label="t('match.col.league')">
<span class="league-readonly">{{ leagueTitle }}</span>
</el-form-item>
<el-form-item :label="t('match.field.kickoff')" required>
<el-date-picker
v-model="form.startTime"
type="datetime"
value-format="YYYY-MM-DDTHH:mm:ss"
:placeholder="t('matchEditor.ph.kickoff')"
style="width: 100%"
/>
<p class="field-hint schedule-timezone-hint">{{ t('match.timezone.platform_hint') }}</p>
</el-form-item>
<div class="teams-row">
<div class="team-col">
<div class="team-col-title">{{ t('match.field.home_team') }}</div>
<el-form-item :label="t('match.field.home_team')" required>
<CountryFlagSelect
v-model="form.homeTeamCode"
size="default"
class="team-country-select"
@update:model-value="onTeamCodeChange('home', $event)"
/>
</el-form-item>
<el-form-item :label="t('match.field.home_en')" label-width="108px">
<el-input v-model="form.homeTeamEn" :placeholder="t('match.ph.home_en')" />
</el-form-item>
<el-form-item :label="t('match.field.home_zh')" label-width="108px">
<el-input v-model="form.homeTeamZh" :placeholder="t('match.ph.home_zh')" />
</el-form-item>
<el-form-item :label="t('match.field.home_ms')" label-width="108px">
<el-input v-model="form.homeTeamMs" :placeholder="t('match.ph.home_ms')" />
</el-form-item>
<el-form-item :label="t('matchEditor.field.home_logo')" label-width="108px">
<LogoUrlField v-model="form.homeTeamLogoUrl" :team-code="form.homeTeamCode" upload-category="teams" />
</el-form-item>
</div>
<div class="team-col">
<div class="team-col-title">{{ t('match.field.away_team') }}</div>
<el-form-item :label="t('match.field.away_team')" required>
<CountryFlagSelect
v-model="form.awayTeamCode"
size="default"
class="team-country-select"
@update:model-value="onTeamCodeChange('away', $event)"
/>
</el-form-item>
<el-form-item :label="t('match.field.away_en')" label-width="108px">
<el-input v-model="form.awayTeamEn" :placeholder="t('match.ph.away_en')" />
</el-form-item>
<el-form-item :label="t('match.field.away_zh')" label-width="108px">
<el-input v-model="form.awayTeamZh" :placeholder="t('match.ph.away_zh')" />
</el-form-item>
<el-form-item :label="t('match.field.away_ms')" label-width="108px">
<el-input v-model="form.awayTeamMs" :placeholder="t('match.ph.away_ms')" />
</el-form-item>
<el-form-item :label="t('matchEditor.field.away_logo')" label-width="108px">
<LogoUrlField v-model="form.awayTeamLogoUrl" :team-code="form.awayTeamCode" upload-category="teams" />
</el-form-item>
</div>
</div>
<el-form-item :label="t('match.field.featured')">
<el-switch v-model="form.isHot" />
</el-form-item>
<p class="field-hint">{{ t('match.hint.create_draft') }}</p>
</el-form>
<template #footer>
<el-button @click="createVisible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" :loading="createLoading" @click="submitCreate">
{{ t('user.btn.create') }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.league-matches-page {
display: flex;
flex-direction: column;
gap: 0;
height: 100%;
min-height: 0;
}
.league-matches-page :deep(.admin-subnav) {
flex-shrink: 0;
margin-bottom: 14px;
}
.league-matches-page .list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: none;
background: transparent;
padding: 0;
box-shadow: none;
}
.team-country-select {
width: 100%;
}
.teams-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 20px;
}
.team-col {
display: flex;
flex-direction: column;
min-width: 0;
padding: 12px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: #fbfaf7;
}
.team-col-title {
font-size: 14px;
font-weight: 750;
color: var(--text);
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-soft);
}
.field-hint {
font-size: 12px;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.schedule-timezone-hint {
margin-top: 4px;
}
.league-readonly {
color: var(--success-text);
font-weight: 700;
}
.settled-hint {
font-size: 13px;
color: var(--warning-text);
font-weight: 600;
}
@media (max-width: 760px) {
.teams-row {
grid-template-columns: 1fr;
gap: 12px;
}
}
</style>

View File

@@ -1,25 +1,39 @@
<script setup lang="ts">
import { ref, watch, h } from 'vue';
import { ref, watch, h, defineAsyncComponent, onActivated } from 'vue';
import { consumeAdminListStale } from '../../utils/adminListStale';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, ElDatePicker } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
import MatchArchiveDialog from '../../components/MatchArchiveDialog.vue';
import { ensureLeagueExpanded } from '../../utils/matchesListState';
import { formatAmount } from '../../utils/format-amount';
import {
formatPlatformMatchDateTime,
platformPickerDateTimeToIso,
} from '@thebet365/shared';
const props = defineProps<{
leagueId: string;
filterStatus: string;
keyword: string;
}>();
const MatchEventEditor = defineAsyncComponent(
() => import('./MatchEventEditor.vue'),
);
const MatchMarketsPanel = defineAsyncComponent(
() => import('./MatchMarketsPanel.vue'),
);
const props = withDefaults(
defineProps<{
leagueId: string;
filterStatus?: string;
keyword?: string;
}>(),
{
filterStatus: '',
keyword: '',
},
);
const emit = defineEmits<{
changed: [];
'add-match': [];
'league-meta': [meta: { isOutrightSettled: boolean }];
}>();
const { t, locale } = useAdminLocale();
@@ -27,23 +41,84 @@ const router = useRouter();
const archiveVisible = ref(false);
const archiveMatchId = ref('');
const archiveTitle = ref('');
const manageDialogVisible = ref(false);
const marketsDialogVisible = ref(false);
const dialogMatchId = ref('');
const dialogMatchTitle = ref('');
const filterHasBets = ref(false);
const orderBy = ref('default');
const localKeyword = ref('');
const localStatus = ref('');
const kickoffRange = ref<[string, string] | null>(null);
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;
async function load() {
loading.value = true;
watch(
() => props.keyword,
(value) => {
localKeyword.value = value ?? '';
scheduleLoad(true);
},
{ immediate: true },
);
watch(
() => props.filterStatus,
(value) => {
localStatus.value = value ?? '';
scheduleLoad(true);
},
{ immediate: true },
);
watch(
() => props.leagueId,
() => scheduleLoad(true),
{ immediate: true },
);
function onSearch() {
matchPage.value = 1;
void load();
}
function onFilterChange() {
matchPage.value = 1;
void load();
}
function resetFilters() {
localKeyword.value = '';
localStatus.value = '';
kickoffRange.value = null;
filterHasBets.value = false;
orderBy.value = 'default';
onFilterChange();
}
async function load(options: { silent?: boolean } = {}) {
if (!options.silent) loading.value = true;
try {
const { data } = await api.get(`/admin/leagues/${props.leagueId}/matches`, {
params: {
status: props.filterStatus || undefined,
keyword: props.keyword.trim() || undefined,
status: localStatus.value || undefined,
keyword: localKeyword.value.trim() || undefined,
locale: locale.value,
page: matchPage.value,
pageSize: matchPageSize.value,
hasBets: filterHasBets.value ? 'true' : undefined,
orderBy: orderBy.value !== 'default' ? orderBy.value : undefined,
startFrom: kickoffRange.value?.[0]
? platformPickerDateTimeToIso(kickoffRange.value[0])
: undefined,
startTo: kickoffRange.value?.[1]
? platformPickerDateTimeToIso(kickoffRange.value[1])
: undefined,
},
});
const payload = data.data as {
@@ -51,20 +126,33 @@ async function load() {
total: number;
page: number;
pageSize: number;
league?: { isOutrightSettled?: boolean };
};
matches.value = payload.items;
matchTotal.value = payload.total;
emit('league-meta', {
isOutrightSettled: Boolean(payload.league?.isOutrightSettled),
});
matchPage.value = payload.page;
matchPageSize.value = payload.pageSize;
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_matches_failed'));
} finally {
loading.value = false;
if (!options.silent) loading.value = false;
}
}
onActivated(() => {
if (!props.leagueId) return;
const stale = consumeAdminListStale();
if (stale || matches.value.length > 0) {
void load({ silent: !stale && matches.value.length > 0 });
}
});
function scheduleLoad(resetPage = false) {
if (!props.leagueId) return;
if (resetPage) matchPage.value = 1;
if (loadTimer) clearTimeout(loadTimer);
loadTimer = setTimeout(() => {
@@ -73,12 +161,6 @@ function scheduleLoad(resetPage = false) {
}, 200);
}
watch(
() => [props.leagueId, props.filterStatus, props.keyword] as const,
() => scheduleLoad(true),
{ immediate: true },
);
function onMatchPageChange(page: number) {
matchPage.value = page;
void load();
@@ -136,23 +218,28 @@ async function close(id: string) {
notifyParent();
}
function beforeLeaveList() {
ensureLeagueExpanded(props.leagueId);
function openManage(id: string, title?: string) {
dialogMatchId.value = id;
dialogMatchTitle.value = title?.trim() || `#${id}`;
manageDialogVisible.value = true;
}
function openManage(id: string) {
beforeLeaveList();
router.push(`/matches/${id}/edit`);
function openMarkets(id: string, title?: string) {
dialogMatchId.value = id;
dialogMatchTitle.value = title?.trim() || `#${id}`;
marketsDialogVisible.value = true;
}
function openMarkets(id: string) {
beforeLeaveList();
router.push(`/matches/${id}/markets`);
function onManageSaved() {
manageDialogVisible.value = false;
notifyParent();
}
function settle(id: string) {
beforeLeaveList();
router.push(`/settlement/${id}`);
void router.push({
path: `/settlement/${id}`,
query: { returnTo: `/matches/leagues/${props.leagueId}` },
});
}
type TagType = '' | 'info' | 'success' | 'warning' | 'danger';
@@ -329,12 +416,73 @@ 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 class="nested-panel-toolbar__filters">
<div class="nested-panel-toolbar__field">
<span class="nested-panel-toolbar__label">{{ t('common.keyword') }}</span>
<el-input
v-model="localKeyword"
:placeholder="t('match.filter.keyword_ph')"
clearable
size="small"
style="width: 168px"
@keyup.enter="onSearch"
/>
</div>
<div class="nested-panel-toolbar__field">
<span class="nested-panel-toolbar__label">{{ t('common.status') }}</span>
<el-select
v-model="localStatus"
:placeholder="t('common.all')"
clearable
size="small"
style="width: 120px"
@change="onFilterChange"
>
<el-option :label="t('match.status.DRAFT')" value="DRAFT" />
<el-option :label="t('match.status.PUBLISHED')" value="PUBLISHED" />
<el-option :label="t('match.status.CLOSED')" value="CLOSED" />
<el-option :label="t('match.status.PENDING_SETTLEMENT')" value="PENDING_SETTLEMENT" />
<el-option :label="t('match.status.SETTLED')" value="SETTLED" />
</el-select>
</div>
<div class="nested-panel-toolbar__field nested-panel-toolbar__field--range">
<span class="nested-panel-toolbar__label">{{ t('match.field.kickoff') }}</span>
<el-date-picker
v-model="kickoffRange"
type="datetimerange"
value-format="YYYY-MM-DDTHH:mm:ss"
:start-placeholder="t('match.filter.kickoff_from')"
:end-placeholder="t('match.filter.kickoff_to')"
size="small"
clearable
style="width: 320px"
@change="onFilterChange"
/>
</div>
<el-button type="primary" size="small" @click="onSearch">
{{ t('common.search') }}
</el-button>
<el-button size="small" @click="resetFilters">
{{ t('common.reset') }}
</el-button>
</div>
<div class="nested-panel-toolbar__extras">
<el-checkbox v-model="filterHasBets" size="default" @change="onFilterChange">
{{ t('match.filter.has_bets') }}
</el-checkbox>
<el-select v-model="orderBy" size="small" style="width: 148px;" @change="onFilterChange">
<el-option :label="t('match.sort.default')" value="default" />
<el-option :label="t('match.sort.kickoff_asc')" value="kickoffAsc" />
<el-option :label="t('match.sort.kickoff_desc')" value="kickoffDesc" />
<el-option :label="t('match.sort.bet_count')" value="betCount" />
<el-option :label="t('match.sort.total_stake')" value="totalStake" />
</el-select>
</div>
</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" />
<div class="nested-table-wrap">
<el-table v-loading="loading" :data="matches" stripe row-key="id" class="nested-match-table">
<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>
@@ -377,7 +525,7 @@ defineExpose({ reload: load });
size="small"
type="primary"
:disabled="!canManage(row)"
@click="openManage(matchId(row))"
@click.stop="openManage(matchId(row), matchTitle(row))"
>
{{ t('matchEditor.manage_btn') }}
</el-button>
@@ -387,7 +535,7 @@ defineExpose({ reload: load });
plain
class="action-btn--markets"
:disabled="!canManage(row)"
@click="openMarkets(matchId(row))"
@click.stop="openMarkets(matchId(row), matchTitle(row))"
>
{{ t('match.btn.markets') }}
</el-button>
@@ -446,8 +594,10 @@ defineExpose({ reload: load });
</div>
</template>
</el-table-column>
</el-table>
<p v-if="!loading && !matches.length" class="empty-hint">{{ t('match.no_fixtures') }}</p>
</el-table>
<p v-if="!loading && !matches.length" class="empty-hint">{{ t('match.no_fixtures') }}</p>
</div>
<div v-if="matchTotal > matchPageSize" class="nested-pager">
<el-pagination
v-model:current-page="matchPage"
@@ -466,28 +616,109 @@ defineExpose({ reload: load });
:title="archiveTitle"
@archived="onMatchArchived"
/>
<el-dialog
v-model="manageDialogVisible"
:title="`${t('matchEditor.title')} · ${dialogMatchTitle}`"
width="920px"
top="4vh"
destroy-on-close
append-to-body
class="match-manage-dialog"
>
<MatchEventEditor
v-if="manageDialogVisible && dialogMatchId"
:match-id-prop="dialogMatchId"
embedded
@saved="onManageSaved"
/>
</el-dialog>
<el-dialog
v-model="marketsDialogVisible"
:title="`${t('matchEditor.section_markets')} · ${dialogMatchTitle}`"
width="min(1200px, 96vw)"
top="3vh"
destroy-on-close
append-to-body
class="match-markets-dialog"
>
<div v-if="marketsDialogVisible && dialogMatchId" class="match-markets-dialog__body">
<MatchMarketsPanel :match-id="dialogMatchId" />
</div>
</el-dialog>
</div>
</template>
<style scoped>
.league-matches-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
height: 100%;
width: min(100%, calc(100vw - 272px));
max-width: calc(100vw - 272px);
padding: 10px 12px 12px;
overflow-x: auto;
overflow-y: visible;
padding: 14px 14px 14px;
overflow: hidden;
background: #fbfaf7;
}
.nested-panel-toolbar {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 8px;
margin-bottom: 8px;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px 16px;
margin: 2px 0 12px;
padding: 10px 14px;
background: #ffffff;
border: 1px solid var(--border-soft, #eaeaea);
border-radius: 6px;
}
.nested-panel-toolbar__filters,
.nested-panel-toolbar__extras {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.nested-panel-toolbar__field {
display: inline-flex;
align-items: center;
gap: 6px;
}
.nested-panel-toolbar__field--range {
min-width: 0;
}
.nested-panel-toolbar__label {
font-size: 12px;
font-weight: 600;
color: var(--text-muted);
white-space: nowrap;
}
.nested-table-wrap {
flex: 1;
min-height: 0;
overflow: auto;
border: 1px solid var(--border-soft, #eaeaea);
border-radius: 6px;
background: #ffffff;
}
.nested-table-wrap :deep(.el-table__header-wrapper) {
position: sticky;
top: 0;
z-index: 2;
background: var(--el-table-header-bg-color, #fafafa);
}
.nested-pager {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
padding-top: 8px;
background: #fbfaf7;
}
.actions-col-header {
display: inline-flex;
@@ -625,4 +856,9 @@ defineExpose({ reload: load });
justify-content: flex-start;
}
}
.match-markets-dialog__body {
height: min(78vh, 900px);
overflow: hidden;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { computed, ref, watch, onActivated } from 'vue';
import { consumeAdminListStale } from '../../utils/adminListStale';
import { useRouter, useRoute } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAdminLocale } from '../../composables/useAdminLocale';
import api from '../../api';
@@ -50,6 +51,7 @@ const emit = defineEmits<{
const { t, locale } = useAdminLocale();
const router = useRouter();
const route = useRoute();
const matchStatus = ref('');
@@ -226,12 +228,19 @@ function goSettle() {
);
return;
}
void router.push(`/settlement/${matchId.value}`);
const title = String(route.query.title ?? '').trim();
void router.push({
path: `/settlement/${matchId.value}`,
query: {
returnTo: `/matches/outrights/leagues/${props.leagueId}`,
...(title ? { title } : {}),
},
});
}
async function load() {
async function load(options: { silent?: boolean } = {}) {
if (!props.leagueId) return;
loading.value = true;
if (!options.silent) loading.value = true;
try {
const { data } = await api.get(`/admin/leagues/${props.leagueId}/outright`);
const payload = data.data as {
@@ -275,10 +284,18 @@ async function load() {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.load_failed'));
} finally {
loading.value = false;
if (!options.silent) loading.value = false;
}
}
onActivated(() => {
if (!props.leagueId) return;
const stale = consumeAdminListStale();
if (stale || matchId.value || selections.value.length > 0) {
void load({ silent: !stale && Boolean(matchId.value || selections.value.length) });
}
});
function resetCustomTeamForm() {
customTeam.value = { teamCode: '', teamZh: '', teamEn: '', logoUrl: '' };
}
@@ -900,18 +917,20 @@ watch(
.outright-odds-panel {
display: flex;
flex-direction: column;
padding: 12px 16px 16px;
border-bottom: 1px solid var(--border-soft);
flex: 1;
min-height: 0;
height: 100%;
padding: 10px 12px 12px;
background: #fbfaf7;
}
.outright-odds-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
flex-shrink: 0;
margin-bottom: 12px;
margin-bottom: 8px;
}
.outright-odds-panel__head-text {
flex: 1;
@@ -986,7 +1005,8 @@ watch(
}
.team-list-scroll {
max-height: min(440px, calc(100vh - 300px));
flex: 1;
min-height: 0;
overflow-y: auto;
padding-right: 4px;
scrollbar-width: thin;
@@ -1005,7 +1025,13 @@ watch(
.team-list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
gap: 6px;
}
@media (min-width: 1440px) {
.team-list {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
.team-row-wrap {
@@ -1027,7 +1053,7 @@ watch(
gap: 8px;
min-width: 0;
flex: 1;
padding: 8px 10px 8px 8px;
padding: 6px 8px 6px 6px;
background: #ffffff;
border: 1px solid var(--border);
border-radius: 8px;

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useAdminLocale } from '../../composables/useAdminLocale';
import AdminSubNav from '../../components/AdminSubNav.vue';
import LeagueOutrightOddsPanel from './LeagueOutrightOddsPanel.vue';
defineOptions({ name: 'AdminLeagueOutrights' });
const route = useRoute();
const { t } = useAdminLocale();
const leagueId = computed(() => String(route.params.leagueId ?? ''));
const leagueTitle = computed(() => {
const title = String(route.query.title ?? '').trim();
return title || `#${leagueId.value}`;
});
</script>
<template>
<div class="admin-list-page league-outrights-page">
<AdminSubNav
:title="leagueTitle"
:subtitle="t('match.league_outrights_subtitle')"
/>
<section class="list-panel">
<LeagueOutrightOddsPanel :league-id="leagueId" />
</section>
</div>
</template>
<style scoped>
.league-outrights-page {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.league-outrights-page :deep(.admin-subnav) {
margin-bottom: 10px;
}
.league-outrights-page .list-panel {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
</style>

View File

@@ -16,11 +16,23 @@ import {
} from '../match-form';
import AdminSubNav from '../../components/AdminSubNav.vue';
const props = withDefaults(
defineProps<{
matchIdProp?: string;
embedded?: boolean;
}>(),
{ embedded: false },
);
const emit = defineEmits<{
saved: [];
}>();
const route = useRoute();
const router = useRouter();
const { t } = useAdminLocale();
const matchId = computed(() => String(route.params.matchId ?? ''));
const matchId = computed(() => props.matchIdProp ?? String(route.params.matchId ?? ''));
const loading = ref(false);
const savingMeta = ref(false);
const status = ref('DRAFT');
@@ -52,7 +64,7 @@ async function load() {
const detail = data.data as AdminMatchDetail;
if (detail.isOutright) {
ElMessage.warning(t('msg.outright_no_edit'));
router.replace('/matches');
if (!props.embedded) router.replace('/matches');
return;
}
status.value = detail.status;
@@ -81,7 +93,8 @@ async function saveMeta() {
try {
await api.put(`/admin/matches/${matchId.value}`, payload);
ElMessage.success(t('msg.saved'));
await load();
emit('saved');
if (!props.embedded) await load();
} catch (e: unknown) {
const err = e as { response?: { data?: { error?: string } } };
ElMessage.error(err.response?.data?.error ?? t('msg.save_failed'));
@@ -92,8 +105,13 @@ async function saveMeta() {
</script>
<template>
<div v-loading="loading" class="match-editor-page page-scroll">
<div
v-loading="loading"
class="match-editor-page"
:class="{ 'match-editor-page--embedded': embedded, 'page-scroll': !embedded }"
>
<AdminSubNav
v-if="!embedded"
:title="t('matchEditor.title')"
:subtitle="`#${matchId}`"
>
@@ -257,6 +275,12 @@ async function saveMeta() {
color: var(--text);
}
.match-editor-page--embedded {
padding-bottom: 0;
max-height: min(78vh, 880px);
overflow-y: auto;
}
.panel {
background: #ffffff;
border: 1px solid var(--border);

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

@@ -15,9 +15,13 @@ onMounted(async () => {
try {
const { data } = await api.get(`/admin/outrights/${matchId}`);
const leagueId = data.data?.leagueId as string | undefined;
if (leagueId) {
await router.replace(`/matches/outrights/leagues/${leagueId}`);
return;
}
await router.replace({
path: '/matches/outrights',
query: leagueId ? { leagueId } : { matchId },
query: { matchId },
});
} catch {
await router.replace('/matches/outrights');

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,12 @@ 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';
import { resolveDevApiTarget } from '../../scripts/resolve-dev-api-target.mjs';
const devApiTarget = resolveDevApiTarget();
export default defineConfig(({ mode }) => {
const analyze = process.env.ANALYZE === '1' || mode === 'analyze';
@@ -9,6 +15,12 @@ export default defineConfig(({ mode }) => {
return {
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
analyze &&
visualizer({
filename: 'dist/stats.html',
@@ -37,7 +49,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';
}
@@ -55,10 +76,11 @@ export default defineConfig(({ mode }) => {
},
publicDir: resolve(__dirname, '../../packages/shared/public'),
server: {
host: true,
port: 5174,
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
'/uploads': { target: 'http://localhost:3000', changeOrigin: true },
'/api': { target: devApiTarget, changeOrigin: true },
'/uploads': { target: devApiTarget, changeOrigin: true },
},
},
};

View File

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

View File

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

@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "player_message_broadcasts" (
"id" BIGSERIAL NOT NULL,
"title" VARCHAR(256) NOT NULL,
"body" TEXT NOT NULL,
"target_type" VARCHAR(16) NOT NULL,
"target_user_id" BIGINT,
"target_username" VARCHAR(64),
"recipient_count" INTEGER NOT NULL DEFAULT 0,
"created_by_id" BIGINT,
"created_by_username" VARCHAR(64),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "player_message_broadcasts_pkey" PRIMARY KEY ("id")
);
-- AlterTable
ALTER TABLE "player_messages" ADD COLUMN "broadcast_id" BIGINT;
-- CreateIndex
CREATE INDEX "player_message_broadcasts_created_at_idx" ON "player_message_broadcasts"("created_at" DESC);
-- CreateIndex
CREATE INDEX "player_messages_broadcast_id_idx" ON "player_messages"("broadcast_id");
-- AddForeignKey
ALTER TABLE "player_messages" ADD CONSTRAINT "player_messages_broadcast_id_fkey" FOREIGN KEY ("broadcast_id") REFERENCES "player_message_broadcasts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- AlterTable
ALTER TABLE "player_message_broadcasts" ADD COLUMN "translations" JSONB;
-- Backfill existing rows as English content
UPDATE "player_message_broadcasts"
SET "translations" = jsonb_build_object(
'en-US', jsonb_build_object('title', "title", 'body', "body")
)
WHERE "translations" IS NULL;

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,45 @@ 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?
broadcastId BigInt? @map("broadcast_id")
readAt DateTime? @map("read_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
broadcast PlayerMessageBroadcast? @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)])
@@index([userId, readAt])
@@index([broadcastId])
@@map("player_messages")
}
model PlayerMessageBroadcast {
id BigInt @id @default(autoincrement())
title String @db.VarChar(256)
body String @db.Text
translations Json? @map("translations")
targetType String @map("target_type") @db.VarChar(16)
targetUserId BigInt? @map("target_user_id")
targetUsername String? @map("target_username") @db.VarChar(64)
recipientCount Int @default(0) @map("recipient_count")
createdById BigInt? @map("created_by_id")
createdByUsername String? @map("created_by_username") @db.VarChar(64)
createdAt DateTime @default(now()) @map("created_at")
messages PlayerMessage[]
@@index([createdAt(sort: Desc)])
@@map("player_message_broadcasts")
}
// ============ 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

@@ -85,6 +85,28 @@ describe('Admin RBAC (SEC010SEC012)', () => {
expect(guardAllows(user, 'settlement.resettle')).toBe(false);
});
it('FINANCE_ADMIN has deposit and cashback permissions but not reset database', () => {
const user = userWithRole('FINANCE_ADMIN');
expect(guardAllows(user, 'deposit.manage')).toBe(true);
expect(guardAllows(user, 'deposit.review')).toBe(true);
expect(guardAllows(user, 'cashback.confirm')).toBe(true);
expect(guardAllows(user, 'settings.reset_database')).toBe(false);
});
it('MATCH_ADMIN cannot manage deposits or confirm cashback', () => {
const user = userWithRole('MATCH_ADMIN');
expect(guardAllows(user, 'deposit.manage')).toBe(false);
expect(guardAllows(user, 'deposit.review')).toBe(false);
expect(guardAllows(user, 'cashback.confirm')).toBe(false);
});
it('SUPPORT cannot confirm cashback or manage deposits', () => {
const user = userWithRole('SUPPORT');
expect(guardAllows(user, 'cashback.confirm')).toBe(false);
expect(guardAllows(user, 'deposit.manage')).toBe(false);
expect(guardAllows(user, 'deposit.review')).toBe(false);
});
it('SUPER_ADMIN bypasses permission checks', () => {
const user = userWithRole('SUPER_ADMIN');
expect(guardAllows(user, 'wallet.deposit')).toBe(true);

View File

@@ -0,0 +1,255 @@
import { Decimal } from '@prisma/client/runtime/library';
import { AdminController } from './admin.controller';
import { AgentsService } from '../../domains/agent/agents.service';
import { createPrismaMock } from '../../testing/prisma-mock';
function stubDeps() {
return {
users: {},
agents: { updateAgentAdmin: jest.fn() } as AgentsService | { updateAgentAdmin: jest.Mock },
wallet: {},
matches: {},
catalogArchive: {},
outright: {},
markets: {},
settlement: {},
cashback: {},
content: { create: jest.fn() },
i18n: {},
audit: { log: jest.fn().mockResolvedValue(undefined) },
bets: {},
prisma: {},
dashboardService: {},
systemConfig: { getInboxNotifySettings: jest.fn() },
bettingLimits: {},
databaseReset: {},
smokeTests: {},
depositService: {},
playerMessages: {
broadcastBannerPromotion: jest.fn(),
broadcastAnnouncementPromotion: jest.fn(),
},
staff: {},
presence: {},
depositCleanup: {},
};
}
function buildController(deps: ReturnType<typeof stubDeps>) {
return new AdminController(
deps.users as never,
deps.agents as never,
deps.wallet as never,
deps.matches as never,
deps.catalogArchive as never,
deps.outright as never,
deps.markets as never,
deps.settlement as never,
deps.cashback as never,
deps.content as never,
deps.i18n as never,
deps.audit as never,
deps.bets as never,
deps.prisma as never,
deps.dashboardService as never,
deps.systemConfig as never,
deps.bettingLimits as never,
deps.databaseReset as never,
deps.smokeTests as never,
deps.depositService as never,
deps.playerMessages as never,
deps.staff as never,
deps.presence as never,
deps.depositCleanup as never,
);
}
describe('AdminController createContent inbox notify gating', () => {
const deps = stubDeps();
const controller = buildController(deps);
const baseDto = {
contentType: 'BANNER' as const,
status: 'ACTIVE' as const,
notifyInbox: true,
translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }],
};
beforeEach(() => {
jest.clearAllMocks();
deps.content.create.mockResolvedValue({ id: '88' });
deps.playerMessages.broadcastBannerPromotion.mockResolvedValue(3);
deps.playerMessages.broadcastAnnouncementPromotion.mockResolvedValue(5);
});
it('does not broadcast banner when inbox.notify.banner is false', async () => {
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
inboxEnabled: true,
deposit: true,
banner: false,
announcement: true,
});
const res = await controller.createContent(baseDto);
expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled();
expect((res.data as { notifiedCount?: number }).notifiedCount).toBeUndefined();
});
it('broadcasts banner when inbox.notify.banner is true', async () => {
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
inboxEnabled: true,
deposit: true,
banner: true,
announcement: false,
});
const res = await controller.createContent(baseDto);
expect(deps.playerMessages.broadcastBannerPromotion).toHaveBeenCalledWith({
contentId: 88n,
translations: [{ locale: 'zh-CN', title: 'Promo', body: 'Body' }],
});
expect((res.data as { notifiedCount?: number }).notifiedCount).toBe(3);
});
it('does not broadcast announcement when inbox.notify.announcement is false', async () => {
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
inboxEnabled: true,
deposit: true,
banner: true,
announcement: false,
});
await controller.createContent({
...baseDto,
contentType: 'NOTICE',
});
expect(deps.playerMessages.broadcastAnnouncementPromotion).not.toHaveBeenCalled();
});
it('skips notify when inbox feature is disabled even if notifyInbox is checked', async () => {
deps.systemConfig.getInboxNotifySettings.mockResolvedValue({
inboxEnabled: false,
deposit: true,
banner: true,
announcement: true,
});
await controller.createContent(baseDto);
expect(deps.playerMessages.broadcastBannerPromotion).not.toHaveBeenCalled();
});
});
describe('AdminController updateAgent suspend wiring', () => {
const agentId = 10n;
const operatorId = 1n;
const tx = {
user: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
userAuth: { create: jest.fn() },
userPreference: { create: jest.fn() },
userInvite: { create: jest.fn(), findUnique: jest.fn() },
agentProfile: { create: jest.fn(), findUnique: jest.fn() },
agentClosure: { create: jest.fn(), findMany: jest.fn() },
};
const prismaBase = {
...tx,
agentProfile: { ...tx.agentProfile, update: jest.fn() },
user: { ...tx.user, updateMany: jest.fn() },
};
const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock };
const systemConfig = {
getAgentHierarchySettings: jest.fn(),
getAgentSuspendSettings: jest.fn(),
};
const audit = { log: jest.fn().mockResolvedValue(undefined) };
let agentsService: AgentsService;
let controller: AdminController;
beforeEach(() => {
jest.clearAllMocks();
prisma.$transaction.mockImplementation(async (arg: unknown) => {
if (Array.isArray(arg)) return Promise.all(arg);
return (arg as (client: typeof tx) => Promise<unknown>)(tx);
});
systemConfig.getAgentSuspendSettings.mockResolvedValue({
suspendFreezeDirectPlayers: true,
suspendBlockPlayerLogin: true,
});
agentsService = new AgentsService(
prisma as never,
{ hashPassword: jest.fn() } as never,
systemConfig as never,
{} as never,
{ recalculateUsedCredit: jest.fn() } as never,
);
jest.spyOn(agentsService, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never);
prisma.agentProfile.findUnique.mockResolvedValue({
userId: agentId,
user: { username: 'agent-a', locale: 'zh-CN' },
parentAgentId: null,
});
prisma.user.updateMany.mockResolvedValue({ count: 2 });
prisma.agentProfile.update.mockResolvedValue({});
prisma.user.update.mockResolvedValue({});
const deps = stubDeps();
deps.agents = agentsService as never;
deps.audit = audit;
controller = buildController(deps);
});
it('forwards bare SUSPENDED body to AgentsService and applies global defaults', async () => {
await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' });
expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled();
expect(prisma.user.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ parentId: agentId, userType: 'PLAYER' }),
data: { status: 'SUSPENDED' },
}),
);
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ blockDirectPlayerLogin: true }),
}),
);
expect(audit.log).toHaveBeenCalledWith(
expect.objectContaining({
operatorId,
action: 'UPDATE_AGENT',
module: 'AGENTS',
targetId: agentId.toString(),
}),
);
});
it('does not freeze players when global default is false and body omits flag', async () => {
systemConfig.getAgentSuspendSettings.mockResolvedValue({
suspendFreezeDirectPlayers: false,
suspendBlockPlayerLogin: false,
});
await controller.updateAgent(operatorId, agentId.toString(), { status: 'SUSPENDED' });
expect(prisma.user.updateMany).not.toHaveBeenCalled();
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ blockDirectPlayerLogin: false }),
}),
);
});
});

View File

@@ -15,10 +15,12 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile, unlink } from 'fs/promises';
import { mkdir, writeFile, unlink, readdir, stat } from 'fs/promises';
import { existsSync } from 'fs';
import { extname, join } from 'path';
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
import { DepositScreenshotCleanupService } from '../../domains/deposit/deposit-screenshot-cleanup.service';
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
@@ -50,6 +52,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,
@@ -60,6 +64,7 @@ import {
IsIn,
Min,
Max,
MaxLength,
Equals,
ValidateIf,
} from 'class-validator';
@@ -272,6 +277,10 @@ class CreateStaffDto {
@IsString()
roleCode!: string;
@IsOptional()
@IsString()
visibleMenus?: string;
}
class UpdateStaffDto {
@@ -287,6 +296,10 @@ class UpdateStaffDto {
@IsString()
@MinLength(8)
password?: string;
@IsOptional()
@IsString()
visibleMenus?: string;
}
class ResetPlayerPasswordDto {
@@ -1046,6 +1059,10 @@ class CreateContentDto {
@IsString()
endTime?: string | null;
@IsOptional()
@IsBoolean()
notifyInbox?: boolean;
@IsArray()
translations!: ContentTranslationDto[];
}
@@ -1085,6 +1102,62 @@ class ContentStatusDto {
status!: string;
}
class InboxNotifySettingsDto {
@IsOptional()
@IsBoolean()
inboxEnabled?: boolean;
@IsOptional()
@IsBoolean()
deposit?: boolean;
@IsOptional()
@IsBoolean()
banner?: boolean;
@IsOptional()
@IsBoolean()
announcement?: boolean;
}
class BroadcastTranslationDto {
@IsString()
locale!: string;
@IsOptional()
@IsString()
@MaxLength(256)
title?: string;
@IsOptional()
@IsString()
body?: string;
}
class CreatePlayerMessageBroadcastDto {
@IsArray()
translations!: BroadcastTranslationDto[];
@IsIn(['ALL', 'USER'])
targetType!: 'ALL' | 'USER';
@ValidateIf((dto: CreatePlayerMessageBroadcastDto) => dto.targetType === 'USER')
@IsString()
@MinLength(1)
targetUsername?: string;
}
class UpdateDepositCleanupConfigDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsNumber()
@Min(1)
keepDays?: number;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@@ -1209,9 +1282,19 @@ export class AdminController {
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
private depositService: DepositService,
private playerMessages: PlayerMessagesService,
private staff: AdminStaffService,
private presence: PresenceService,
private depositCleanup: DepositScreenshotCleanupService,
) {}
@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 +1583,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(
@@ -1945,6 +2045,10 @@ export class AdminController {
@Query('locale') locale?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('hasBets') hasBets?: string,
@Query('orderBy') orderBy?: string,
@Query('startFrom') startFrom?: string,
@Query('startTo') startTo?: string,
) {
const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
status: status || undefined,
@@ -1952,6 +2056,10 @@ export class AdminController {
locale: locale || undefined,
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20,
hasBets: hasBets || undefined,
orderBy: orderBy || undefined,
startFrom: startFrom ? new Date(startFrom) : undefined,
startTo: startTo ? new Date(startTo) : undefined,
});
return jsonResponse(result);
}
@@ -2789,6 +2897,29 @@ export class AdminController {
return jsonResponse(preview);
}
@Get('matches/:id/settlement/preview')
@RequirePermissions(P.settlement, P.reports)
async getActiveSettlementPreview(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const matchId = BigInt(id);
const preview = await this.settlement.getActivePreview(matchId, {
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
});
return jsonResponse(preview);
}
@Get('matches/:id/settlement/history')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementHistory(@Param('id') id: string) {
const matchId = BigInt(id);
const history = await this.settlement.getMatchSettlementHistory(matchId);
return jsonResponse(history);
}
@Get('settlement/:batchId/preview-items')
@RequirePermissions(P.settlement)
async getSettlementPreviewItems(
@@ -3007,8 +3138,24 @@ export class AdminController {
@Query('category') category?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('imagesOnly') imagesOnly?: string,
) {
const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
const where: {
category?: string | { in: string[] };
mimeType?: { startsWith: string };
} = {};
if (category && UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
where.category = category;
} else if (imagesOnly === '1' || imagesOnly === 'true') {
// 媒体库选择器:默认可选 banners/teams/contents/payments不含 deposits
where.category = { in: ['banners', 'teams', 'contents', 'payments'] };
}
if (imagesOnly === '1' || imagesOnly === 'true') {
where.mimeType = { startsWith: 'image/' };
}
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
@@ -3104,6 +3251,179 @@ export class AdminController {
return urls;
}
@Get('files/storage-stats')
@RequirePermissions(P.content)
async getStorageStats() {
const statsGroup = await this.prisma.uploadedFile.groupBy({
by: ['category'],
_count: { _all: true },
_sum: { size: true }
});
const categories = statsGroup.map((g) => ({
category: g.category,
count: g._count._all,
sizeBytes: g._sum.size ?? 0,
}));
// Calculate deposits on disk
let depositCount = 0;
let depositSizeBytes = 0;
const depositsDir = join(getUploadRoot(), 'deposits');
try {
const files = await readdir(depositsDir);
for (const file of files) {
const filePath = join(depositsDir, file);
try {
const fileStats = await stat(filePath);
if (fileStats.isFile()) {
depositCount++;
depositSizeBytes += fileStats.size;
}
} catch {}
}
} catch {}
categories.push({
category: 'deposits',
count: depositCount,
sizeBytes: depositSizeBytes,
});
const totalCount = categories.reduce((sum, c) => sum + c.count, 0);
const totalSizeBytes = categories.reduce((sum, c) => sum + c.sizeBytes, 0);
return jsonResponse({
categories,
total: {
count: totalCount,
sizeBytes: totalSizeBytes,
},
});
}
@Get('deposits/screenshot-cleanup-config')
@RequirePermissions(P.content)
async getScreenshotCleanupConfig() {
const config = await this.systemConfig.getDepositScreenshotCleanupConfig();
return jsonResponse(config);
}
@Put('deposits/screenshot-cleanup-config')
@RequirePermissions(P.content)
async updateScreenshotCleanupConfig(@Body() body: UpdateDepositCleanupConfigDto) {
const config = await this.systemConfig.updateDepositScreenshotCleanupConfig(body);
return jsonResponse(config);
}
@Delete('deposits/screenshots')
@RequirePermissions(P.content)
async cleanOldScreenshots(
@CurrentUser('id') operatorId: bigint,
@Query('before') beforeStr?: string,
) {
if (!beforeStr) throw appBadRequest('BEFORE_DATE_REQUIRED');
const beforeDate = new Date(beforeStr);
if (Number.isNaN(beforeDate.getTime())) {
throw appBadRequest('INVALID_BEFORE_DATE');
}
if (beforeDate.getTime() > Date.now()) {
throw appBadRequest('BEFORE_DATE_CANNOT_BE_FUTURE');
}
const result = await this.depositCleanup.cleanOldDepositScreenshots(beforeDate);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'PURGE_DEPOSIT_SCREENSHOTS',
module: 'MEDIA',
afterData: JSON.stringify({
before: beforeDate.toISOString(),
cleanedCount: result.cleaned,
freedBytes: result.freedBytes,
}),
});
return jsonResponse(result);
}
@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('player-message-broadcasts')
@RequirePermissions(P.content, P.reports)
async listPlayerMessageBroadcasts(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) || 1 : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 20), 50);
const result = await this.playerMessages.listBroadcasts(p, size);
return jsonResponse(result);
}
@Post('player-message-broadcasts')
@RequirePermissions(P.content)
async createPlayerMessageBroadcast(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreatePlayerMessageBroadcastDto,
) {
const operator = await this.prisma.user.findUnique({
where: { id: operatorId },
select: { username: true },
});
const item = await this.playerMessages.createCustomBroadcast({
translations: dto.translations,
targetType: dto.targetType,
targetUsername: dto.targetUsername,
createdById: operatorId,
createdByUsername: operator?.username ?? null,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'SEND_PLAYER_MESSAGE_BROADCAST',
module: 'CONTENT',
afterData: JSON.stringify({
id: item.id,
title: item.title,
targetType: item.targetType,
recipientCount: item.recipientCount,
}),
});
return jsonResponse(item);
}
@Delete('player-message-broadcasts/:id')
@RequirePermissions(P.content)
async deletePlayerMessageBroadcast(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const result = await this.playerMessages.deleteBroadcast(BigInt(id));
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'DELETE_PLAYER_MESSAGE_BROADCAST',
module: 'CONTENT',
targetId: id,
afterData: JSON.stringify(result),
});
return jsonResponse(result);
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(
@@ -3128,8 +3448,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 inboxNotify = await this.systemConfig.getInboxNotifySettings();
if (inboxNotify.inboxEnabled) {
const translations = createDto.translations.map((tr) => ({
locale: tr.locale,
title: tr.title,
body: tr.body,
}));
if (createDto.contentType === 'BANNER' && inboxNotify.banner) {
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
contentId: BigInt(item.id),
translations,
});
} else if (
(createDto.contentType === 'NOTICE' || createDto.contentType === 'TICKER') &&
inboxNotify.announcement
) {
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
contentId: BigInt(item.id),
translations,
});
}
}
}
return jsonResponse({ ...item, notifiedCount });
}
@Put('contents/:id')
@@ -3349,6 +3695,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,17 +186,20 @@ 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();
const hotMatches = (allMatches as Array<{ isHot?: boolean; status?: string }>).filter(
(m) => m.isHot && m.status !== 'SETTLED',
);
const todayMatches = (allMatches as Array<{ startTime: string }>).filter((m) => {
const todayMatches = (allMatches as Array<{ startTime: string; status?: string }>).filter((m) => {
if (m.status === 'SETTLED') return false;
const kickoff = new Date(m.startTime);
return !Number.isNaN(kickoff.getTime()) && isInLocalTodayMatchWindow(kickoff, now, timeZone);
});
@@ -198,6 +212,8 @@ export class PlayerController {
notices: announcements,
hotMatches,
todayMatches,
upcomingMatches,
inboxEnabled,
});
}
@@ -245,6 +261,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 +479,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

@@ -32,12 +32,30 @@ describe('AgentsService', () => {
},
};
const prisma = createPrismaMock(tx);
const prismaBase = {
...tx,
agentProfile: {
...tx.agentProfile,
update: jest.fn(),
},
user: {
...tx.user,
updateMany: jest.fn(),
},
};
const prisma = createPrismaMock(prismaBase) as typeof prismaBase & { $transaction: jest.Mock };
prisma.$transaction.mockImplementation(async (arg: unknown) => {
if (Array.isArray(arg)) {
return Promise.all(arg);
}
return (arg as (client: typeof tx) => Promise<unknown>)(tx);
});
const auth = {
hashPassword: jest.fn(),
};
const systemConfig = {
getAgentHierarchySettings: jest.fn(),
getAgentSuspendSettings: jest.fn(),
};
const network = {};
const credit = {
@@ -56,13 +74,20 @@ describe('AgentsService', () => {
credit as never,
);
systemConfig.getAgentHierarchySettings.mockResolvedValue({ maxAgentLevel: 3 });
systemConfig.getAgentHierarchySettings.mockResolvedValue({
maxAgentLevel: 3,
defaultSubAgentCreditRatio: 50,
});
systemConfig.getAgentSuspendSettings.mockResolvedValue({
suspendFreezeDirectPlayers: true,
suspendBlockPlayerLogin: true,
});
auth.hashPassword.mockResolvedValue('hashed-password');
tx.agentProfile.findUnique.mockResolvedValue({
prisma.agentProfile.findUnique.mockResolvedValue({
userId: parentAgentId,
level: 1,
creditLimit: new Decimal(1000),
usedCredit: new Decimal(0),
creditLimit: new Decimal(10000),
usedCredit: new Decimal(2000),
cashbackRate: new Decimal(10),
maxSingleDeposit: null,
maxDailyDeposit: null,
@@ -91,10 +116,41 @@ describe('AgentsService', () => {
password: 'secret',
level: 2,
parentAgentId,
creditLimit: 300,
});
expect(tx.agentProfile.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ creditLimit: 4000 }),
}),
);
expect(credit.recalculateUsedCredit).toHaveBeenCalledWith(parentAgentId);
expect(credit.recalculateUsedCredit).not.toHaveBeenCalledWith(createdAgentId);
});
it('applies global suspend defaults when suspending without explicit flags', async () => {
jest.spyOn(service, 'getAgentAdminDetail').mockResolvedValue({ userId: '10' } as never);
prisma.agentProfile.findUnique.mockResolvedValue({
userId: 10n,
user: { username: 'agent-a', locale: 'zh-CN' },
parentAgentId: null,
});
prisma.user.updateMany.mockResolvedValue({ count: 2 });
prisma.agentProfile.update.mockResolvedValue({});
prisma.user.update.mockResolvedValue({});
await service.updateAgentAdmin(10n, { status: 'SUSPENDED' });
expect(systemConfig.getAgentSuspendSettings).toHaveBeenCalled();
expect(prisma.user.updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ parentId: 10n, userType: 'PLAYER' }),
data: { status: 'SUSPENDED' },
}),
);
expect(prisma.agentProfile.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ blockDirectPlayerLogin: true }),
}),
);
});
});

View File

@@ -40,6 +40,15 @@ export class AgentsService {
return agentLevel < maxLevel;
}
/** 与 Admin 创建下级代理预填逻辑一致:可用授信 × 比例(%),非 100% 时向下取整到百位 */
computeDefaultSubAgentCredit(availableCredit: number, ratioPercent: number): number {
if (availableCredit <= 0) return 0;
const pct = Math.min(100, Math.max(1, ratioPercent)) / 100;
const raw = availableCredit * pct;
const rounded = pct >= 1 ? availableCredit : Math.floor(raw / 100) * 100;
return Math.min(availableCredit, Math.max(0, rounded));
}
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
return this.network.buildAgentAncestorChainMap(parentAgentIds);
}
@@ -923,11 +932,17 @@ export class AgentsService {
});
}
// Handle status change (per-action cascade freeze / login block)
// Handle status change (per-action cascade freeze / login block; 未传参时用全局默认)
if (data.status) {
const profilePatch: Prisma.AgentProfileUpdateInput = { status: data.status };
let freezeDirectPlayers = false;
if (data.status === 'SUSPENDED') {
profilePatch.blockDirectPlayerLogin = data.blockDirectPlayerLogin === true;
const suspendDefaults = await this.systemConfig.getAgentSuspendSettings();
const blockDirectPlayerLogin =
data.blockDirectPlayerLogin ?? suspendDefaults.suspendBlockPlayerLogin;
freezeDirectPlayers =
data.freezeDirectPlayers ?? suspendDefaults.suspendFreezeDirectPlayers;
profilePatch.blockDirectPlayerLogin = blockDirectPlayerLogin === true;
} else if (data.status === 'ACTIVE') {
profilePatch.blockDirectPlayerLogin = false;
}
@@ -943,7 +958,7 @@ export class AgentsService {
}),
]);
if (data.status === 'SUSPENDED' && data.freezeDirectPlayers) {
if (data.status === 'SUSPENDED' && freezeDirectPlayers) {
await this.prisma.user.updateMany({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
data: { status: 'SUSPENDED' },
@@ -1156,20 +1171,31 @@ export class AgentsService {
) {
await this.validateAgentLevel(data.level, data.parentAgentId);
let resolvedCreditLimit = data.creditLimit;
let resolvedCashbackRate = data.cashbackRate ?? 0;
if (data.parentAgentId) {
const parentProfile = await this.prisma.agentProfile.findUnique({
where: { userId: data.parentAgentId },
select: { cashbackRate: true },
select: { cashbackRate: true, creditLimit: true, usedCredit: true },
});
resolvedCashbackRate =
data.cashbackRate ?? (parentProfile ? Number(parentProfile.cashbackRate) : 0);
if (resolvedCreditLimit === undefined && parentProfile) {
const hierarchy = await this.systemConfig.getAgentHierarchySettings();
const available = new Decimal(parentProfile.creditLimit).sub(parentProfile.usedCredit);
resolvedCreditLimit = this.computeDefaultSubAgentCredit(
available.toNumber(),
hierarchy.defaultSubAgentCreditRatio,
);
}
await this.assertChildAgentWithinParent(data.parentAgentId, {
creditLimit: data.creditLimit ?? 0,
creditLimit: resolvedCreditLimit ?? 0,
cashbackRate: resolvedCashbackRate,
maxSingleDeposit: data.maxSingleDeposit,
maxDailyDeposit: data.maxDailyDeposit,
});
} else if (resolvedCreditLimit === undefined) {
resolvedCreditLimit = 0;
}
const maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
@@ -1208,7 +1234,7 @@ export class AgentsService {
userId: user.id,
level: data.level,
parentAgentId: data.parentAgentId,
creditLimit: data.creditLimit ?? 0,
creditLimit: resolvedCreditLimit ?? 0,
cashbackRate: resolvedCashbackRate,
maxSingleDeposit,
maxDailyDeposit,

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,357 @@ 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 },
league: {
isActive: true,
deletedAt: null,
NOT: {
matches: {
some: { isOutright: true, status: 'SETTLED', deletedAt: null },
},
},
},
}),
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,
}),
);
});
});
describe('MatchesService listPublished', () => {
let prisma: {
match: { findMany: jest.Mock };
entityTranslation: { findMany: jest.Mock };
};
let service: MatchesService;
beforeEach(() => {
prisma = {
match: { findMany: jest.fn().mockResolvedValue([]) },
entityTranslation: {
findMany: jest.fn().mockResolvedValue([]),
},
};
service = new MatchesService(
prisma as never,
{ syncWithLeaguePublished: jest.fn() } as never,
{ betStatsForMatches: jest.fn().mockResolvedValue(new Map()) } as never,
);
});
it('excludes settled fixtures and leagues with settled outright', async () => {
await service.listPublished('zh-CN', undefined, { includeMarkets: false });
expect(prisma.match.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
isOutright: false,
sportType: 'FOOTBALL',
deletedAt: null,
league: {
isActive: true,
deletedAt: null,
NOT: {
matches: {
some: { isOutright: true, status: 'SETTLED', deletedAt: null },
},
},
},
},
}),
);
});
});
describe('MatchesService listAdminLeagueMatches', () => {
const leagueId = BigInt(1);
let prisma: {
match: { findMany: jest.Mock; findFirst: jest.Mock; count: jest.Mock };
entityTranslation: { findFirst: jest.Mock; findMany: jest.Mock };
};
let matchBetStats: { betStatsForMatches: jest.Mock };
let service: MatchesService;
beforeEach(() => {
prisma = {
match: {
findMany: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
count: jest.fn(),
},
entityTranslation: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
},
};
matchBetStats = { betStatsForMatches: jest.fn() };
service = new MatchesService(
prisma as never,
{ syncWithLeaguePublished: jest.fn() } as never,
matchBetStats as never,
);
});
it('filters by hasBets = true', async () => {
const mockMatches = [
{ id: BigInt(10), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M1', homeTeamId: BigInt(2), awayTeamId: BigInt(3), homeTeam: { code: 'H1' }, awayTeam: { code: 'A1' } },
{ id: BigInt(11), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M2', homeTeamId: BigInt(4), awayTeamId: BigInt(5), homeTeam: { code: 'H2' }, awayTeam: { code: 'A2' } },
];
prisma.match.findMany.mockResolvedValue(mockMatches);
const statsMap = new Map();
statsMap.set('10', { betCount: 5, totalStake: '500.00', pendingCount: 0 });
statsMap.set('11', { betCount: 0, totalStake: '0.00', pendingCount: 0 });
matchBetStats.betStatsForMatches.mockResolvedValue(statsMap);
const result = await service.listAdminLeagueMatches(leagueId, {
hasBets: 'true',
});
expect(result.items).toHaveLength(1);
expect(result.items[0].id).toBe('10');
});
it('sorts by betCount', async () => {
const mockMatches = [
{ id: BigInt(10), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M1', homeTeamId: BigInt(2), awayTeamId: BigInt(3), homeTeam: { code: 'H1' }, awayTeam: { code: 'A1' } },
{ id: BigInt(11), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M2', homeTeamId: BigInt(4), awayTeamId: BigInt(5), homeTeam: { code: 'H2' }, awayTeam: { code: 'A2' } },
];
prisma.match.findMany.mockResolvedValue(mockMatches);
const statsMap = new Map();
statsMap.set('10', { betCount: 5, totalStake: '500.00', pendingCount: 0 });
statsMap.set('11', { betCount: 15, totalStake: '1500.00', pendingCount: 0 });
matchBetStats.betStatsForMatches.mockResolvedValue(statsMap);
const result = await service.listAdminLeagueMatches(leagueId, {
orderBy: 'betCount',
});
expect(result.items).toHaveLength(2);
expect(result.items[0].id).toBe('11');
expect(result.items[1].id).toBe('10');
});
it('sorts by totalStake', async () => {
const mockMatches = [
{ id: BigInt(10), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M1', homeTeamId: BigInt(2), awayTeamId: BigInt(3), homeTeam: { code: 'H1' }, awayTeam: { code: 'A1' } },
{ id: BigInt(11), startTime: new Date(), displayOrder: 0, status: 'PUBLISHED', isOutright: false, matchName: 'M2', homeTeamId: BigInt(4), awayTeamId: BigInt(5), homeTeam: { code: 'H2' }, awayTeam: { code: 'A2' } },
];
prisma.match.findMany.mockResolvedValue(mockMatches);
const statsMap = new Map();
statsMap.set('10', { betCount: 5, totalStake: '1200.00', pendingCount: 0 });
statsMap.set('11', { betCount: 15, totalStake: '800.00', pendingCount: 0 });
matchBetStats.betStatsForMatches.mockResolvedValue(statsMap);
const result = await service.listAdminLeagueMatches(leagueId, {
orderBy: 'totalStake',
});
expect(result.items).toHaveLength(2);
expect(result.items[0].id).toBe('10');
expect(result.items[1].id).toBe('11');
});
});
describe('MatchesService createMatch outright guard', () => {
const leagueId = BigInt(1);
it('rejects fixture creation when outright is settled', async () => {
const prisma = {
match: { create: jest.fn() },
};
const outright = {
assertLeagueAllowsNewFixtures: jest.fn().mockRejectedValue(
Object.assign(new Error('LEAGUE_OUTRIGHT_SETTLED'), {
response: { code: 'LEAGUE_OUTRIGHT_SETTLED' },
}),
),
};
const service = new MatchesService(
prisma as never,
outright as never,
{ betStatsForMatches: jest.fn() } as never,
);
await expect(
service.createMatch({
leagueId,
homeTeamId: BigInt(10),
awayTeamId: BigInt(11),
startTime: new Date('2026-06-01T12:00:00Z'),
}),
).rejects.toMatchObject({
response: expect.objectContaining({ code: 'LEAGUE_OUTRIGHT_SETTLED' }),
});
expect(outright.assertLeagueAllowsNewFixtures).toHaveBeenCalledWith(leagueId);
expect(prisma.match.create).not.toHaveBeenCalled();
});
});

View File

@@ -105,6 +105,7 @@ export class MatchesService {
externalStatus: string;
}>;
}) {
await this.outright.assertLeagueAllowsNewFixtures(data.leagueId);
const status = data.status ?? 'DRAFT';
return this.prisma.match.create({
data: {
@@ -492,8 +493,11 @@ export class MatchesService {
isOutright: true,
deletedAt: null,
},
select: { id: true, leagueId: true },
select: { id: true, leagueId: true, status: true },
});
const outrightStatusByLeague = new Map(
outrightMatches.map((m) => [m.leagueId.toString(), m.status]),
);
const outrightTeamCounts = new Map<string, number>();
if (outrightMatches.length > 0) {
const matchIdToLeagueId = new Map(
@@ -543,6 +547,10 @@ export class MatchesService {
fixtureTeamSets.get(item.id)?.size ?? 0;
(item as { outrightTeamCount?: number }).outrightTeamCount =
outrightTeamCounts.get(item.id) ?? 0;
const outrightStatus = outrightStatusByLeague.get(item.id) ?? null;
(item as { outrightStatus?: string | null }).outrightStatus = outrightStatus;
(item as { isOutrightSettled?: boolean }).isOutrightSettled =
outrightStatus === 'SETTLED';
}
return { items, total, page: opts.page, pageSize: opts.pageSize };
@@ -556,14 +564,33 @@ export class MatchesService {
locale?: string;
page?: number;
pageSize?: number;
hasBets?: string;
orderBy?: string;
startFrom?: Date;
startTo?: Date;
},
) {
const outrightMatch = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
orderBy: { id: 'asc' },
});
const leagueMeta = {
outrightStatus: outrightMatch?.status ?? null,
isOutrightSettled: outrightMatch?.status === 'SETTLED',
};
const where: Prisma.MatchWhereInput = {
leagueId,
deletedAt: null,
isOutright: false,
};
if (opts.status) where.status = opts.status;
if (opts.startFrom || opts.startTo) {
where.startTime = {};
if (opts.startFrom) where.startTime.gte = opts.startFrom;
if (opts.startTo) where.startTime.lte = opts.startTo;
}
const kw = opts.keyword?.trim();
if (kw) {
where.OR = [
@@ -574,12 +601,87 @@ export class MatchesService {
}
const page = Math.max(1, opts.page ?? 1);
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20));
if (opts.hasBets === 'true' || opts.orderBy === 'betCount' || opts.orderBy === 'totalStake') {
const allRows = await this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
});
const locale = opts.locale ?? 'zh-CN';
const betStatsMap = await this.betStatsForMatches(allRows.map((m) => m.id));
const itemsWithStats = await Promise.all(
allRows.map(async (m) => {
const [homeTeamName, awayTeamName] = await Promise.all([
this.getTranslation('TEAM', m.homeTeamId, locale),
this.getTranslation('TEAM', m.awayTeamId, locale),
]);
const raw = betStatsMap.get(m.id.toString());
const betCount = raw?.betCount ?? 0;
const totalStake = raw?.totalStake ?? '0';
const pendingBets = raw?.pendingCount ?? 0;
return {
id: m.id.toString(),
status: m.status,
isOutright: m.isOutright,
isHot: m.isHot,
displayOrder: m.displayOrder,
startTime: m.startTime,
matchName: m.matchName,
homeTeamName,
awayTeamName,
homeTeam: { code: m.homeTeam.code },
awayTeam: { code: m.awayTeam.code },
betCount,
totalStake,
pendingBets,
};
}),
);
let filteredItems = itemsWithStats;
if (opts.hasBets === 'true') {
filteredItems = itemsWithStats.filter((item) => item.betCount > 0);
}
if (opts.orderBy === 'betCount') {
filteredItems.sort((a, b) => b.betCount - a.betCount);
} else if (opts.orderBy === 'totalStake') {
filteredItems.sort((a, b) => {
const stakeA = parseFloat(a.totalStake);
const stakeB = parseFloat(b.totalStake);
return stakeB - stakeA;
});
} else if (opts.orderBy === 'kickoffAsc') {
filteredItems.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
} else if (opts.orderBy === 'kickoffDesc') {
filteredItems.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
} else {
filteredItems.sort((a, b) => {
if (a.displayOrder !== b.displayOrder) {
return a.displayOrder - b.displayOrder;
}
return new Date(b.startTime).getTime() - new Date(a.startTime).getTime();
});
}
const total = filteredItems.length;
const paginatedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize);
return { items: paginatedItems, total, page, pageSize, league: leagueMeta };
}
const orderBy =
opts.orderBy === 'kickoffAsc'
? [{ startTime: 'asc' as const }, { displayOrder: 'asc' as const }]
: opts.orderBy === 'kickoffDesc'
? [{ startTime: 'desc' as const }, { displayOrder: 'asc' as const }]
: [{ displayOrder: 'asc' as const }, { startTime: 'desc' as const }];
const [total, rows] = await Promise.all([
this.prisma.match.count({ where }),
this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
}),
@@ -614,10 +716,8 @@ export class MatchesService {
};
}),
);
return { items, total, page, pageSize };
return { items, total, page, pageSize, league: leagueMeta };
}
/** 批量汇总多场关联注单(按 bet 去重计注单数) */
async betStatsForMatches(
matchIds: bigint[],
): Promise<Map<string, MatchBetStatsSummary>> {
@@ -799,9 +899,32 @@ export class MatchesService {
async getAdminMatchDetail(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
const scoreRow = await this.prisma.matchScore.findUnique({
let scoreRow = await this.prisma.matchScore.findUnique({
where: { matchId },
});
if (!scoreRow) {
const previewBatch = await this.prisma.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW' },
orderBy: { createdAt: 'desc' },
});
if (previewBatch) {
scoreRow = {
htHomeScore: previewBatch.htHomeScore,
htAwayScore: previewBatch.htAwayScore,
ftHomeScore: previewBatch.ftHomeScore,
ftAwayScore: previewBatch.ftAwayScore,
homeCorners: previewBatch.homeCorners,
awayCorners: previewBatch.awayCorners,
homeYellowCards: previewBatch.homeYellowCards,
awayYellowCards: previewBatch.awayYellowCards,
homeRedCards: previewBatch.homeRedCards,
awayRedCards: previewBatch.awayRedCards,
homeCards: previewBatch.homeCards,
awayCards: previewBatch.awayCards,
winnerTeamId: null,
} as any;
}
}
const markets = await this.prisma.market.findMany({
where: { matchId },
include: { selections: { orderBy: { sortOrder: 'asc' } } },
@@ -871,6 +994,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),
@@ -1429,11 +1554,19 @@ export class MatchesService {
const matches = await this.prisma.match.findMany({
where: {
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT', 'SETTLED'] },
status: { in: ['PUBLISHED', 'CLOSED', 'PENDING_SETTLEMENT'] },
isOutright: false,
sportType: 'FOOTBALL',
deletedAt: null,
league: { isActive: true, deletedAt: null },
league: {
isActive: true,
deletedAt: null,
NOT: {
matches: {
some: { isOutright: true, status: 'SETTLED', deletedAt: null },
},
},
},
...(leagueId ? { leagueId } : {}),
},
include: {
@@ -1459,6 +1592,50 @@ 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,
NOT: {
matches: {
some: { isOutright: true, status: 'SETTLED', 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 +1658,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

@@ -0,0 +1,33 @@
jest.mock('./wc2026-outright.sync', () => ({
syncWc2026OutrightMarket: jest.fn().mockResolvedValue(undefined),
}));
import { OutrightService } from './outright.service';
describe('OutrightService listForPlayer', () => {
let prisma: { match: { findMany: jest.Mock } };
let service: OutrightService;
beforeEach(() => {
prisma = {
match: { findMany: jest.fn().mockResolvedValue([]) },
};
service = new OutrightService(prisma as never, {} as never);
});
it('returns only published outright markets for player browse', async () => {
await service.listForPlayer('zh-CN');
expect(prisma.match.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
status: 'PUBLISHED',
isOutright: true,
sportType: 'FOOTBALL',
deletedAt: null,
league: { isActive: true, deletedAt: null },
}),
}),
);
});
});

View File

@@ -259,6 +259,18 @@ export class OutrightService {
await this.syncOutrightStatusWithLeague(existing, league);
}
/** 优胜赛(冠军盘)已结算时禁止再新增单场 */
async assertLeagueAllowsNewFixtures(leagueId: bigint) {
const outright = await this.prisma.match.findFirst({
where: { leagueId, isOutright: true, deletedAt: null },
select: { status: true },
orderBy: { id: 'asc' },
});
if (outright?.status === 'SETTLED') {
throw appBadRequest('LEAGUE_OUTRIGHT_SETTLED');
}
}
/** 联赛下尚未结算/取消的单场数量(不含冠军盘) */
async countUnsettledLeagueFixtures(leagueId: bigint): Promise<number> {
return this.prisma.match.count({
@@ -288,7 +300,9 @@ export class OutrightService {
where: { leagueId, isOutright: true, deletedAt: null },
orderBy: { id: 'asc' },
});
if (!match) return { addedCount: 0, reopenedCount: 0 };
if (!match || match.status === 'SETTLED') {
return { addedCount: 0, reopenedCount: 0 };
}
return this.syncSelectionsFromLeagueFixtures(match.id);
}
@@ -694,11 +708,12 @@ export class OutrightService {
league: { isActive: true, deletedAt: null },
},
include: {
score: true,
markets: {
where: { marketType: OUTRIGHT_MARKET_TYPE, status: 'OPEN' },
where: { marketType: OUTRIGHT_MARKET_TYPE },
include: {
selections: {
where: { status: 'OPEN' },
where: { selectionCode: { not: PLACEHOLDER_TEAM_CODE } },
orderBy: { sortOrder: 'asc' },
},
},
@@ -716,10 +731,24 @@ export class OutrightService {
const market = match.markets[0];
if (!market) continue;
const isSettled = match.status === 'SETTLED';
const visibleSelections = isSettled
? market.selections
: market.selections.filter((sel) => sel.status === 'OPEN');
if (!visibleSelections.length) continue;
let winnerTeamCode: string | null = null;
if (match.score?.winnerTeamId) {
const winner = await this.prisma.team.findUnique({
where: { id: match.score.winnerTeamId },
select: { code: true },
});
winnerTeamCode = winner?.code ?? null;
}
const selections = await Promise.all(
market.selections
.filter((sel) => sel.selectionCode !== PLACEHOLDER_TEAM_CODE)
.map(async (sel) => {
visibleSelections.map(async (sel) => {
const team = await this.prisma.team.findUnique({
where: { code: sel.selectionCode },
});
@@ -743,12 +772,11 @@ export class OutrightService {
logoUrl: team?.logoUrl ?? null,
odds: sel.odds.toString(),
oddsVersion: sel.oddsVersion.toString(),
isWinner: Boolean(winnerTeamCode && sel.selectionCode === winnerTeamCode),
};
}),
);
if (!selections.length) continue;
const [titleZh, titleEn, titleMs] = await Promise.all([
this.getOutrightTitle(match.id, 'zh-CN'),
this.getOutrightTitle(match.id, 'en-US'),
@@ -764,6 +792,11 @@ export class OutrightService {
match.matchName?.trim() ||
`*${leagueName || 'Outright'} ${locale.startsWith('zh') ? '冠军' : 'Winner'}`;
const bettingOpen =
match.status === 'PUBLISHED' &&
market.status === 'OPEN' &&
market.selections.some((sel) => sel.status === 'OPEN');
results.push({
id: match.id.toString(),
leagueId: match.leagueId.toString(),
@@ -771,6 +804,9 @@ export class OutrightService {
leagueName: leagueName || '',
title: title.startsWith('*') ? title : `*${title}`,
marketId: market.id.toString(),
status: match.status,
bettingOpen,
winnerTeamCode,
selectionCount: selections.length,
selections,
});

View File

@@ -0,0 +1,121 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { join, dirname } from 'path';
import { existsSync } from 'fs';
import { mkdir, stat, unlink, writeFile } from 'fs/promises';
@Injectable()
export class DepositScreenshotCleanupService implements OnModuleInit {
private readonly logger = new Logger(DepositScreenshotCleanupService.name);
constructor(
private prisma: PrismaService,
private systemConfigService: SystemConfigService,
) {}
async onModuleInit() {
await this.ensureExpiredPlaceholderExists();
}
/**
* 确保已过期/已清理的截图占位图存在
*/
async ensureExpiredPlaceholderExists() {
// 1x1 像素透明 PNG
const defaultExpiredPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const root = getUploadRoot();
const expiredPath = join(root, 'defaults', 'expired.png');
try {
await mkdir(dirname(expiredPath), { recursive: true });
// 强制写入以确保生成最新的透明 1x1 占位图
await writeFile(expiredPath, Buffer.from(defaultExpiredPngBase64, 'base64'));
this.logger.log('Ensured expired screenshot default placeholder (transparent 1x1).');
} catch (err) {
this.logger.error('Failed to create default expired screenshot placeholder', err);
}
}
/**
* 每日凌晨3点定时执行清理
*/
@Cron('0 0 3 * * *')
async handleScheduledCleanup() {
this.logger.log('Scheduled deposit screenshot cleanup job started');
try {
const config = await this.systemConfigService.getDepositScreenshotCleanupConfig();
if (!config.enabled) {
this.logger.log('Scheduled cleanup is disabled, skipping');
return;
}
const beforeDate = new Date();
beforeDate.setDate(beforeDate.getDate() - config.keepDays);
this.logger.log(`Cleaning deposit screenshots older than ${config.keepDays} days (before ${beforeDate.toISOString()})`);
const result = await this.cleanOldDepositScreenshots(beforeDate);
this.logger.log(`Scheduled cleanup completed. Cleaned: ${result.cleaned} screenshots, Freed: ${(result.freedBytes / 1024 / 1024).toFixed(2)} MB`);
} catch (err) {
this.logger.error('Scheduled cleanup failed', err);
}
}
/**
* 清理指定日期之前的已处理充值订单截图
*/
async cleanOldDepositScreenshots(before: Date): Promise<{ cleaned: number; freedBytes: number }> {
const orders = await this.prisma.depositOrder.findMany({
where: {
createdAt: { lt: before },
status: { in: ['APPROVED', 'REJECTED'] },
screenshotUrl: {
startsWith: '/uploads/',
not: { startsWith: '/uploads/defaults/' },
},
},
select: {
id: true,
screenshotUrl: true,
},
});
let cleaned = 0;
let freedBytes = 0;
const batchSize = 100;
const root = getUploadRoot();
for (let i = 0; i < orders.length; i += batchSize) {
const chunk = orders.slice(i, i + batchSize);
await Promise.all(
chunk.map(async (order) => {
const url = order.screenshotUrl;
if (!url.startsWith('/uploads/')) return;
const relative = url.slice('/uploads/'.length);
// 安全路径校验,防止目录穿越
if (!relative || relative.includes('..') || relative.includes('\\')) return;
const filePath = join(root, relative);
let size = 0;
try {
const fileStats = await stat(filePath);
size = fileStats.size;
await unlink(filePath);
freedBytes += size;
} catch {
// 文件不存在或已被删除,静默跳过,但依然更新数据库
}
await this.prisma.depositOrder.update({
where: { id: order.id },
data: { screenshotUrl: '/uploads/defaults/expired.png' },
});
cleaned++;
})
);
}
return { cleaned, freedBytes };
}
}

View File

@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';
import { DepositService } from './deposit.service';
import { DepositScreenshotCleanupService } from './deposit-screenshot-cleanup.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],
providers: [DepositService],
exports: [DepositService],
imports: [WalletModule, AgentsModule, PlayerMessagesModule, SystemConfigModule],
providers: [DepositService, DepositScreenshotCleanupService],
exports: [DepositService, DepositScreenshotCleanupService],
})
export class DepositModule {}

View File

@@ -19,6 +19,7 @@ describe('DepositService', () => {
},
user: {
findFirst: jest.fn(),
findUnique: jest.fn(),
},
agentProfile: {
findUnique: jest.fn(),
@@ -38,12 +39,30 @@ 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,
banner: true,
announcement: 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 +76,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 +153,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

@@ -0,0 +1,83 @@
import * as bcrypt from 'bcryptjs';
import { AuthService } from './auth.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('AuthService player login', () => {
const prisma = {
user: {
findUnique: jest.fn(),
},
userAuth: {
update: jest.fn(),
},
};
const jwt = { sign: jest.fn().mockReturnValue('token') };
const config = { get: jest.fn().mockReturnValue('1h') };
const systemConfig = {};
const invites = {};
const sms = {};
const audit = { log: jest.fn() };
let service: AuthService;
beforeEach(() => {
jest.clearAllMocks();
service = new AuthService(
prisma as never,
jwt as never,
config as never,
systemConfig as never,
invites as never,
sms as never,
audit as never,
);
});
it('blocks player login when parent agent is suspended with blockDirectPlayerLogin', async () => {
const passwordHash = await bcrypt.hash('Player@123', 4);
prisma.user.findUnique
.mockResolvedValueOnce({
id: 10n,
username: 'player1',
userType: 'PLAYER',
status: 'ACTIVE',
parentId: 20n,
auth: { passwordHash, loginFailCount: 0, lockedUntil: null },
adminRole: null,
})
.mockResolvedValueOnce({
userType: 'AGENT',
status: 'SUSPENDED',
agentProfile: { blockDirectPlayerLogin: true },
});
await expect(service.login('player1', 'Player@123', 'player')).rejects.toMatchObject(
expectAppError('PARENT_AGENT_SUSPENDED'),
);
expect(jwt.sign).not.toHaveBeenCalled();
});
it('allows player login when parent agent suspended without blockDirectPlayerLogin', async () => {
const passwordHash = await bcrypt.hash('Player@123', 4);
prisma.user.findUnique
.mockResolvedValueOnce({
id: 10n,
username: 'player1',
userType: 'PLAYER',
status: 'ACTIVE',
parentId: 20n,
locale: 'zh-CN',
auth: { passwordHash, loginFailCount: 0, lockedUntil: null },
adminRole: null,
})
.mockResolvedValueOnce({
userType: 'AGENT',
status: 'SUSPENDED',
agentProfile: { blockDirectPlayerLogin: false },
});
prisma.userAuth.update.mockResolvedValue({});
const result = await service.login('player1', 'Player@123', 'player');
expect(result.token).toBe('token');
});
});

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

@@ -0,0 +1,126 @@
import { Decimal } from '@prisma/client/runtime/library';
import { CashbackService } from './cashback.service';
describe('CashbackService previewBatch', () => {
const tx = {
cashbackBatch: {
findMany: jest.fn(),
create: jest.fn(),
},
cashbackItem: {
create: jest.fn(),
},
cashbackBet: {
create: jest.fn(),
},
};
const prisma = {
cashbackBatch: {
findFirst: jest.fn(),
},
cashbackBet: {
findMany: jest.fn(),
},
bet: {
findMany: jest.fn(),
},
cashbackRule: {
findMany: jest.fn(),
},
agentProfile: {
findMany: jest.fn(),
},
user: {
findMany: jest.fn(),
},
wallet: {
findMany: jest.fn(),
},
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {};
const systemConfig = {
getPlatformDirectCashbackSettings: jest.fn(),
};
let service: CashbackService;
const platformPlayerId = 100n;
const adminInvitePlayerId = 101n;
const adminSponsorId = 200n;
const periodStart = new Date('2026-06-01T00:00:00.000Z');
const periodEnd = new Date('2026-06-01T23:59:59.999Z');
beforeEach(() => {
jest.clearAllMocks();
service = new CashbackService(prisma as never, funds as never, systemConfig as never);
systemConfig.getPlatformDirectCashbackSettings.mockResolvedValue({
platformDirectRate: 0.02,
adminInviteRate: 0.05,
});
prisma.cashbackBatch.findFirst.mockResolvedValue(null);
prisma.cashbackBet.findMany.mockResolvedValue([]);
prisma.cashbackRule.findMany.mockResolvedValue([]);
prisma.agentProfile.findMany.mockResolvedValue([]);
tx.cashbackBatch.findMany.mockResolvedValue([]);
tx.cashbackItem.create.mockResolvedValue({});
tx.cashbackBet.create.mockResolvedValue({});
tx.cashbackBatch.create.mockImplementation(({ data }: { data: Record<string, unknown> }) =>
Promise.resolve({ id: 1n, batchNo: 'CB-TEST', ...data }),
);
});
it('uses platformDirectRate for platform-direct players', async () => {
prisma.bet.findMany.mockResolvedValue([
{
id: 1n,
userId: platformPlayerId,
stake: new Decimal(1000),
status: 'WON',
settledAt: new Date('2026-06-01T12:00:00.000Z'),
user: { id: platformPlayerId, parentId: null, inviteSponsorId: null },
selections: [{ marketType: 'FT_1X2' }],
},
]);
prisma.user.findMany.mockResolvedValue([
{ id: platformPlayerId, username: 'pd1', parent: null },
]);
prisma.wallet.findMany.mockResolvedValue([
{ userId: platformPlayerId, availableBalance: new Decimal(0) },
]);
const result = await service.previewBatch(periodStart, periodEnd);
expect(result.items).toHaveLength(1);
expect(result.items[0].amount.toString()).toBe('20');
expect(result.totalAmount.toString()).toBe('20');
});
it('uses adminInviteRate for admin-invited platform-direct players', async () => {
prisma.bet.findMany.mockResolvedValue([
{
id: 2n,
userId: adminInvitePlayerId,
stake: new Decimal(1000),
status: 'LOST',
settledAt: new Date('2026-06-01T12:00:00.000Z'),
user: {
id: adminInvitePlayerId,
parentId: null,
inviteSponsorId: adminSponsorId,
},
selections: [{ marketType: 'FT_1X2' }],
},
]);
prisma.user.findMany
.mockResolvedValueOnce([{ id: adminSponsorId, userType: 'ADMIN' }])
.mockResolvedValueOnce([{ id: adminInvitePlayerId, username: 'inv1', parent: null }]);
prisma.wallet.findMany.mockResolvedValue([
{ userId: adminInvitePlayerId, availableBalance: new Decimal(0) },
]);
const result = await service.previewBatch(periodStart, periodEnd);
expect(result.items).toHaveLength(1);
expect(result.items[0].amount.toString()).toBe('50');
expect(result.totalAmount.toString()).toBe('50');
});
});

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

@@ -1,4 +1,5 @@
import { Decimal } from '@prisma/client/runtime/library';
import { BET_LIMIT_KEYS } from '../../betting/betting-limits.service';
import type { AgentsService } from '../../agent/agents.service';
import type { BetsService } from '../../betting/bets.service';
import type { SettlementService } from '../../settlement/settlement.service';
@@ -12,7 +13,7 @@ import {
teardownBetFlowFixture,
} from './smoke-test.bet-flow.fixture';
export const BET_FLOW_PROBE_COUNT = 5;
export const BET_FLOW_PROBE_COUNT = 13;
export type BetFlowProbeDeps = {
prisma: PrismaService;
@@ -22,6 +23,38 @@ export type BetFlowProbeDeps = {
agents: AgentsService;
};
async function upsertSystemConfig(
prisma: PrismaService,
key: string,
value: string,
snapshots: Array<{ key: string; value: string | null }>,
) {
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
snapshots.push({ key, value: row?.configValue ?? null });
await prisma.systemConfig.upsert({
where: { configKey: key },
create: { configKey: key, configValue: value, description: 'smoke test' },
update: { configValue: value },
});
}
async function restoreSystemConfigs(
prisma: PrismaService,
snapshots: Array<{ key: string; value: string | null }>,
) {
for (const snap of snapshots) {
if (snap.value === null) {
await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } });
} else {
await prisma.systemConfig.upsert({
where: { configKey: snap.key },
create: { configKey: snap.key, configValue: snap.value, description: 'smoke test restore' },
update: { configValue: snap.value },
});
}
}
}
async function confirmMatchSettlement(
deps: BetFlowProbeDeps,
fx: BetFlowFixtureIds,
@@ -201,6 +234,401 @@ export function createBetFlowProbes(deps: BetFlowProbeDeps): SmokeTestCaseDef[]
}
},
},
{
id: 'BF006',
suite: 'bet-flow',
name: '录分校验:半场比分不能大于全场',
description: 'HT>FT 时 recordScore 返回 SETTLEMENT_SCORE_INVALID',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet);
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
await expectAppErrorThrows(
'recordScore invalid HT>FT',
async () => {
await deps.settlement.recordScore(fx.matchId, 2, 0, 1, 0, fx.operatorId);
},
'SETTLEMENT_SCORE_INVALID',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF007',
suite: 'bet-flow',
name: '确认结算:拒绝已作废的旧 PREVIEW 批次',
description: '新 preview 后旧 batch 变为 CANCELLEDconfirm 旧 batchId 失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet);
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { status: 'CLOSED', closeTime: new Date() },
});
const previewA = await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 0,
});
await deps.settlement.previewSettlement(fx.matchId, fx.operatorId, {
htHome: 0,
htAway: 0,
ftHome: 2,
ftAway: 1,
});
const stale = await deps.prisma.settlementBatch.findUnique({
where: { id: previewA.batch.id },
});
expectEqual('old batch cancelled', stale?.status, 'CANCELLED');
await expectAppErrorThrows(
'confirm cancelled batch',
async () => {
await deps.settlement.confirmSettlement(previewA.batch.id, fx.operatorId);
},
'SETTLEMENT_BATCH_ALREADY_CONFIRMED',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF008',
suite: 'bet-flow',
name: '串动作废VOID 腿续算,已有 LOSE 腿不退本',
description: '跨场串关一场 LOSE 后另一场取消payout=0 而非退 stake',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const matchB = await deps.prisma.match.create({
data: {
sportType: 'FOOTBALL',
leagueId: fx.leagueId,
homeTeamId: fx.homeTeamId,
awayTeamId: fx.awayTeamId,
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000),
status: 'PUBLISHED',
publishTime: new Date(),
},
});
const marketB = await deps.prisma.market.create({
data: {
matchId: matchB.id,
marketType: 'FT_1X2',
period: 'FT',
status: 'OPEN',
selections: {
create: [
{
selectionCode: 'HOME',
selectionName: 'Home B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 0,
},
{
selectionCode: 'AWAY',
selectionName: 'Away B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 1,
},
],
},
},
include: { selections: true },
});
const awayB = marketB.selections.find((s) => s.selectionCode === 'AWAY')!;
try {
const parlay = await deps.bets.placeParlayBet(
fx.playerId,
null,
[
{ selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion },
{ selectionId: awayB.id, oddsVersion: awayB.oddsVersion },
],
100,
`smoke-parlay-void-${fx.runId}`,
);
await deps.prisma.match.update({
where: { id: matchB.id },
data: { status: 'CLOSED', closeTime: new Date() },
});
const previewB = await deps.settlement.previewSettlement(matchB.id, fx.operatorId, {
htHome: 1,
htAway: 0,
ftHome: 2,
ftAway: 1,
});
await deps.settlement.confirmSettlement(previewB.batch.id, fx.operatorId);
let w = await deps.wallet.getWallet(fx.playerId);
expectEqual('frozen while pending', w.frozenBalance.toString(), '100');
await deps.settlement.voidMatchBets(fx.matchId);
const settled = await deps.prisma.bet.findUnique({ where: { id: parlay.id } });
expectEqual('settled status', settled?.status, 'LOST');
expectEqual('actualReturn', settled?.actualReturn.toString(), '0');
w = await deps.wallet.getWallet(fx.playerId);
expectEqual('available after void parlay', w.availableBalance.toString(), '900');
expectEqual('frozen after void parlay', w.frozenBalance.toString(), '0');
} finally {
const bets = await deps.prisma.bet.findMany({
where: { userId: fx.playerId },
select: { id: true },
});
const betIds = bets.map((b) => b.id);
if (betIds.length) {
await deps.prisma.betSelection.deleteMany({ where: { betId: { in: betIds } } });
await deps.prisma.bet.deleteMany({ where: { id: { in: betIds } } });
}
const batchIds = (
await deps.prisma.settlementBatch.findMany({
where: { matchId: { in: [fx.matchId, matchB.id] } },
select: { id: true },
})
).map((b) => b.id);
if (batchIds.length) {
await deps.prisma.settlementItem.deleteMany({ where: { batchId: { in: batchIds } } });
await deps.prisma.settlementBatch.deleteMany({ where: { id: { in: batchIds } } });
}
await deps.prisma.matchScore.deleteMany({
where: { matchId: { in: [fx.matchId, matchB.id] } },
});
const marketIds = (
await deps.prisma.market.findMany({
where: { matchId: matchB.id },
select: { id: true },
})
).map((m) => m.id);
if (marketIds.length) {
await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } });
}
await deps.prisma.match.deleteMany({ where: { id: matchB.id } });
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF009',
suite: 'bet-flow',
name: '赔率版本不一致BetsService 拒绝下注',
uatRef: 'B003',
description: '提交旧 oddsVersion 应返回 ODDS_CHANGED',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
try {
await deps.prisma.marketSelection.update({
where: { id: fx.homeSelectionId },
data: { oddsVersion: { increment: 1 } },
});
await expectAppErrorThrows(
'placeSingleBet odds changed',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
50,
`smoke-odds-${fx.runId}`,
);
},
'ODDS_CHANGED',
);
const count = await deps.prisma.bet.count({ where: { userId: fx.playerId } });
expectEqual('bet count', count, 0);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF010',
suite: 'bet-flow',
name: '赛前限制:开赛后拒绝下注',
description: 'startTime 已过后 placeSingleBet 返回 PRE_MATCH_ONLY',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 500 });
try {
await deps.prisma.match.update({
where: { id: fx.matchId },
data: { startTime: new Date(Date.now() - 60_000) },
});
await expectAppErrorThrows(
'placeSingleBet after kickoff',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
50,
`smoke-prematch-${fx.runId}`,
);
},
'PRE_MATCH_ONLY',
);
} finally {
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF011',
suite: 'bet-flow',
name: '每日投注上限:超额拒单',
description: 'dailyStakeLimit=150 时第二笔 100 注单失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.dailyStakeLimit, '150', snapshots);
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
100,
`smoke-daily-1-${fx.runId}`,
);
await expectAppErrorThrows(
'placeSingleBet daily limit',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.drawSelectionId,
fx.drawOddsVersion,
100,
`smoke-daily-2-${fx.runId}`,
);
},
'DAILY_STAKE_LIMIT',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF012',
suite: 'bet-flow',
name: '串关限额:超 maxStakeParlay 拒单',
description: 'maxStakeParlay=80 时下 100 串关失败',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const matchB = await deps.prisma.match.create({
data: {
sportType: 'FOOTBALL',
leagueId: fx.leagueId,
homeTeamId: fx.homeTeamId,
awayTeamId: fx.awayTeamId,
startTime: new Date(Date.now() + 48 * 60 * 60 * 1000),
status: 'PUBLISHED',
publishTime: new Date(),
},
});
const marketB = await deps.prisma.market.create({
data: {
matchId: matchB.id,
marketType: 'FT_1X2',
period: 'FT',
status: 'OPEN',
selections: {
create: [
{
selectionCode: 'AWAY',
selectionName: 'Away B',
odds: new Decimal('2.00'),
oddsVersion: BigInt(1),
status: 'OPEN',
sortOrder: 0,
},
],
},
},
include: { selections: true },
});
const awayB = marketB.selections[0]!;
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.maxStakeParlay, '80', snapshots);
await expectAppErrorThrows(
'placeParlayBet max stake',
async () => {
await deps.bets.placeParlayBet(
fx.playerId,
null,
[
{ selectionId: fx.homeSelectionId, oddsVersion: fx.homeOddsVersion },
{ selectionId: awayB.id, oddsVersion: awayB.oddsVersion },
],
100,
`smoke-parlay-max-${fx.runId}`,
);
},
'MAX_STAKE',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
const markets = await deps.prisma.market.findMany({
where: { matchId: matchB.id },
select: { id: true },
});
const marketIds = markets.map((m) => m.id);
if (marketIds.length) {
await deps.prisma.marketSelection.deleteMany({ where: { marketId: { in: marketIds } } });
await deps.prisma.market.deleteMany({ where: { id: { in: marketIds } } });
}
await deps.prisma.match.deleteMany({ where: { id: matchB.id } });
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF013',
suite: 'bet-flow',
name: '改 SystemConfig 后拒单minStake',
description: 'minStake=200 时下 100 单关返回 MIN_STAKE',
run: async () => {
const fx = await createBetFlowFixture(deps.prisma, deps.wallet, { initialBalance: 1000 });
const snapshots: Array<{ key: string; value: string | null }> = [];
try {
await upsertSystemConfig(deps.prisma, BET_LIMIT_KEYS.minStake, '200', snapshots);
await expectAppErrorThrows(
'placeSingleBet min stake',
async () => {
await deps.bets.placeSingleBet(
fx.playerId,
null,
fx.homeSelectionId,
fx.homeOddsVersion,
100,
`smoke-min-${fx.runId}`,
);
},
'MIN_STAKE',
);
} finally {
await restoreSystemConfigs(deps.prisma, snapshots);
await teardownBetFlowFixture(deps.prisma, fx);
}
},
},
{
id: 'BF005',
suite: 'bet-flow',

View File

@@ -380,6 +380,36 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
expectEqual('payout', payout.toNumber(), 100, { stake: 100, legs });
},
},
{
id: 'S020',
suite: 'settlement',
name: '串关 VOID 腿 + LOSE 腿:整单全输',
description: '比赛取消腿按 odds=1.0 续算,已有 LOSE 腿时 payout=0',
run: () => {
const legs = [
{ odds: 2.0, result: 'VOID' as const },
{ odds: 2.0, result: 'LOSE' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'LOST', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 0, { stake: 100, legs });
},
},
{
id: 'S021',
suite: 'settlement',
name: '串关 VOID 腿 + WIN 腿:按 WIN 腿赔率结算',
description: '作废腿不参与升赔,其余 WIN 腿正常连乘',
run: () => {
const legs = [
{ odds: 2.0, result: 'VOID' as const },
{ odds: 1.5, result: 'WIN' as const },
];
const { betResult, payout } = calculateParlayPayout(100, legs);
expectEqual('betResult', betResult, 'WON', { stake: 100, legs });
expectEqual('payout', payout.toNumber(), 150, { stake: 100, legs });
},
},
{
id: 'OUT001',
suite: 'settlement',
@@ -431,17 +461,6 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
},
// —— Betting rules ——
{
id: 'B003',
suite: 'betting',
name: '赔率版本不一致应拒绝',
uatRef: 'B003',
run: () => {
const submitted = BigInt(1);
const current = BigInt(2);
expectTrue('version mismatch', submitted !== current, { submitted: '1', current: '2' });
},
},
{
id: 'B006',
suite: 'betting',
@@ -760,7 +779,10 @@ export const SMOKE_TEST_CASES: SmokeTestCaseDef[] = [
];
export const SMOKE_SUITE_META: Record<string, { name: string; description: string }> = {
settlement: { name: '结算引擎', description: '独赢、波胆、让球、大小、串关、冠军盘' },
settlement: {
name: '结算引擎',
description: '独赢、波胆、让球、大小、串关、冠军盘、作废腿续算',
},
settlement_helpers: { name: '结算辅助', description: '选项代码与中文快照名映射' },
betting: { name: '下注规则', description: '串关限制、赔率版本、四分之一盘' },
betting_limits: { name: '投注限额', description: '最小/最大投注与派彩上限校验' },
@@ -771,4 +793,8 @@ export const SMOKE_SUITE_META: Record<string, { name: string; description: strin
name: '下注结算链路',
description: '真实 DB下注→冻结→录分→结算→钱包/代理额度(临时数据自动清理)',
},
config: {
name: '系统配置',
description: '真实 DBSystemConfig 接线后 AgentsService 行为(临时数据自动清理)',
},
};

View File

@@ -0,0 +1,197 @@
import { Decimal } from '@prisma/client/runtime/library';
import {
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
} from '../../../shared/config/system-config.service';
import type { AgentsService } from '../../agent/agents.service';
import type { PrismaService } from '../../../shared/prisma/prisma.service';
import { expectEqual, expectTrue } from './smoke-test.helpers';
import type { SmokeTestCaseDef } from './smoke-test.cases';
export const CONFIG_PROBE_COUNT = 2;
export type ConfigProbeDeps = {
prisma: PrismaService;
agents: AgentsService;
};
async function upsertBooleanConfig(
prisma: PrismaService,
key: string,
value: boolean,
snapshots: Array<{ key: string; value: string | null }>,
) {
const row = await prisma.systemConfig.findUnique({ where: { configKey: key } });
snapshots.push({ key, value: row?.configValue ?? null });
await prisma.systemConfig.upsert({
where: { configKey: key },
create: {
configKey: key,
configValue: value ? 'true' : 'false',
description: 'smoke config probe',
},
update: { configValue: value ? 'true' : 'false' },
});
}
async function restoreConfigs(
prisma: PrismaService,
snapshots: Array<{ key: string; value: string | null }>,
) {
for (const snap of snapshots) {
if (snap.value === null) {
await prisma.systemConfig.deleteMany({ where: { configKey: snap.key } });
} else {
await prisma.systemConfig.upsert({
where: { configKey: snap.key },
create: {
configKey: snap.key,
configValue: snap.value,
description: 'smoke config restore',
},
update: { configValue: snap.value },
});
}
}
}
export function createConfigProbes(deps: ConfigProbeDeps): SmokeTestCaseDef[] {
return [
{
id: 'CFG001',
suite: 'config',
name: '代理停用默认:全局 freeze 级联冻结直属玩家',
description: 'AgentsService.updateAgentAdmin 未传 flag 时读取 agent.suspend_freeze_direct_players',
run: async () => {
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const snapshots: Array<{ key: string; value: string | null }> = [];
let agentId: bigint | undefined;
let playerId: bigint | undefined;
try {
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
true,
snapshots,
);
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
false,
snapshots,
);
const agent = await deps.prisma.user.create({
data: {
username: `smoke_cfg_ag_${runId}`,
userType: 'AGENT',
status: 'ACTIVE',
auth: { create: { passwordHash: 'smoke' } },
agentProfile: {
create: { level: 1, creditLimit: new Decimal(10000) },
},
},
});
agentId = agent.id;
await deps.prisma.agentClosure.create({
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
});
const player = await deps.prisma.user.create({
data: {
username: `smoke_cfg_pl_${runId}`,
userType: 'PLAYER',
status: 'ACTIVE',
parentId: agent.id,
auth: { create: { passwordHash: 'smoke' } },
},
});
playerId = player.id;
await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' });
const frozen = await deps.prisma.user.findUnique({ where: { id: player.id } });
expectEqual('player status after suspend', frozen?.status, 'SUSPENDED');
} finally {
if (playerId) {
await deps.prisma.userAuth.deleteMany({ where: { userId: playerId } });
await deps.prisma.user.deleteMany({ where: { id: playerId } });
}
if (agentId) {
await deps.prisma.agentClosure.deleteMany({
where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] },
});
await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } });
await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } });
await deps.prisma.user.deleteMany({ where: { id: agentId } });
}
await restoreConfigs(deps.prisma, snapshots);
}
},
},
{
id: 'CFG002',
suite: 'config',
name: '代理停用默认:全局 block 禁止直属玩家登录',
description: 'AgentsService.updateAgentAdmin 未传 flag 时写入 blockDirectPlayerLogin',
run: async () => {
const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const snapshots: Array<{ key: string; value: string | null }> = [];
let agentId: bigint | undefined;
try {
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
false,
snapshots,
);
await upsertBooleanConfig(
deps.prisma,
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
true,
snapshots,
);
const agent = await deps.prisma.user.create({
data: {
username: `smoke_cfg2_ag_${runId}`,
userType: 'AGENT',
status: 'ACTIVE',
auth: { create: { passwordHash: 'smoke' } },
agentProfile: {
create: { level: 1, creditLimit: new Decimal(10000) },
},
},
});
agentId = agent.id;
await deps.prisma.agentClosure.create({
data: { ancestorId: agent.id, descendantId: agent.id, depth: 0 },
});
await deps.agents.updateAgentAdmin(agent.id, { status: 'SUSPENDED' });
const profile = await deps.prisma.agentProfile.findUnique({
where: { userId: agent.id },
});
expectTrue(
'blockDirectPlayerLogin persisted',
profile?.blockDirectPlayerLogin === true,
{ value: profile?.blockDirectPlayerLogin },
);
} finally {
if (agentId) {
await deps.prisma.agentClosure.deleteMany({
where: { OR: [{ ancestorId: agentId }, { descendantId: agentId }] },
});
await deps.prisma.agentProfile.deleteMany({ where: { userId: agentId } });
await deps.prisma.userAuth.deleteMany({ where: { userId: agentId } });
await deps.prisma.user.deleteMany({ where: { id: agentId } });
}
await restoreConfigs(deps.prisma, snapshots);
}
},
},
];
}

View File

@@ -7,6 +7,7 @@ import { SettlementService } from '../../settlement/settlement.service';
import { WalletService } from '../../ledger/wallet.service';
import { SMOKE_SUITE_META, SMOKE_TEST_CASES, type SmokeTestCaseDef } from './smoke-test.cases';
import { BET_FLOW_PROBE_COUNT, createBetFlowProbes } from './smoke-test.bet-flow-probes';
import { CONFIG_PROBE_COUNT, createConfigProbes } from './smoke-test.config-probes';
import { createDatabaseProbes, DATABASE_PROBE_COUNT } from './smoke-test.db-probes';
import {
beginSmokeSteps,
@@ -49,6 +50,7 @@ export class SmokeTestService {
}
counts.set('database', DATABASE_PROBE_COUNT);
counts.set('bet-flow', BET_FLOW_PROBE_COUNT);
counts.set('config', CONFIG_PROBE_COUNT);
return [...counts.entries()].map(([id, caseCount]) => ({
id,
@@ -77,6 +79,7 @@ export class SmokeTestService {
const staticCases = SMOKE_TEST_CASES.filter((c) => !allow || allow.has(c.suite));
const runDb = !allow || allow.has('database');
const runBetFlow = !allow || allow.has('bet-flow');
const runConfig = !allow || allow.has('config');
const results: SmokeTestCaseResult[] = [];
@@ -102,6 +105,15 @@ export class SmokeTestService {
}
}
if (runConfig) {
for (const probe of createConfigProbes({
prisma: this.prisma,
agents: this.agents,
})) {
results.push(await this.executeCase(probe));
}
}
const finished = Date.now();
const passed = results.filter((r) => r.status === 'PASS').length;
const failed = results.filter((r) => r.status === 'FAIL').length;

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,229 @@
import { PlayerMessagesService } from './player-messages.service';
describe('PlayerMessagesService', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prisma: any = {
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(),
},
playerMessageBroadcast: {
findMany: jest.fn(),
count: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
user: {
findUnique: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
},
$transaction: jest.fn(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma)),
};
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: '系统维护中',
}),
],
});
});
it('creates custom broadcast to all active players', async () => {
prisma.user.findMany.mockResolvedValue([
{ id: 1n, username: 'p1', locale: 'zh-CN', preferences: { locale: 'zh-CN' } },
{ id: 2n, username: 'p2', locale: 'en-US', preferences: null },
]);
prisma.playerMessageBroadcast.create.mockResolvedValue({
id: 9n,
title: 'Hello',
body: '<p>World</p>',
translations: {
'en-US': { title: 'Hello', body: '<p>World</p>' },
'zh-CN': { title: '你好', body: '<p>内容</p>' },
},
targetType: 'ALL',
targetUserId: null,
targetUsername: null,
recipientCount: 2,
createdById: 99n,
createdByUsername: 'admin',
createdAt: new Date('2026-06-22T10:00:00.000Z'),
});
const result = await service.createCustomBroadcast({
translations: [
{ locale: 'en-US', title: 'Hello', body: '<p>World</p>' },
{ locale: 'zh-CN', title: '你好', body: '<p>内容</p>' },
],
targetType: 'ALL',
createdById: 99n,
createdByUsername: 'admin',
});
expect(result.recipientCount).toBe(2);
expect(prisma.playerMessage.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({ userId: 1n, type: 'ADMIN_CUSTOM', title: '你好', broadcastId: 9n }),
expect.objectContaining({ userId: 2n, type: 'ADMIN_CUSTOM', title: 'Hello', broadcastId: 9n }),
],
});
});
it('deletes broadcast and cascades player messages', async () => {
prisma.playerMessageBroadcast.findUnique.mockResolvedValue({
id: 5n,
recipientCount: 3,
});
prisma.playerMessageBroadcast.delete.mockResolvedValue({ id: 5n });
const result = await service.deleteBroadcast(5n);
expect(result).toEqual({ deleted: true, recipientCount: 3 });
expect(prisma.playerMessageBroadcast.delete).toHaveBeenCalledWith({ where: { id: 5n } });
});
});

View File

@@ -0,0 +1,679 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appNotFound } from '../../shared/common/app-error';
export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO'
| 'ADMIN_CUSTOM';
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' | 'ADMIN_CUSTOM'>,
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,
};
}
export type BroadcastTranslation = {
title: string;
body: string;
};
export type BroadcastTranslations = Record<string, BroadcastTranslation>;
export type BroadcastTranslationInput = {
locale: string;
title?: string | null;
body?: string | null;
};
function normalizeBroadcastTranslations(
inputs: BroadcastTranslationInput[],
): BroadcastTranslations {
const map: BroadcastTranslations = {};
for (const tr of inputs) {
const locale = resolveLocale(tr.locale);
map[locale] = {
title: tr.title?.trim() ?? '',
body: tr.body?.trim() ?? '',
};
}
return map;
}
function resolveBroadcastContent(
translations: BroadcastTranslations,
locale: string | null | undefined,
): BroadcastTranslation | null {
const chain = [resolveLocale(locale), 'en-US', 'zh-CN', 'ms-MY'];
const seen = new Set<string>();
for (const loc of chain) {
if (seen.has(loc)) continue;
seen.add(loc);
const tr = translations[loc];
if (!tr) continue;
const title = tr.title?.trim();
const body = tr.body?.trim();
if (title || body) {
return {
title: title || stripHtml(body).slice(0, 256) || 'Notification',
body: body || '',
};
}
}
for (const tr of Object.values(translations)) {
const title = tr.title?.trim();
const body = tr.body?.trim();
if (title || body) {
return {
title: title || stripHtml(body).slice(0, 256) || 'Notification',
body: body || '',
};
}
}
return null;
}
function parseBroadcastTranslations(value: Prisma.JsonValue | null | undefined): BroadcastTranslations {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const map: BroadcastTranslations = {};
for (const [locale, raw] of Object.entries(value as Record<string, unknown>)) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
const row = raw as Record<string, unknown>;
map[locale] = {
title: typeof row.title === 'string' ? row.title : '',
body: typeof row.body === 'string' ? row.body : '',
};
}
return map;
}
function mapBroadcastRow(row: {
id: bigint;
title: string;
body: string;
translations: Prisma.JsonValue | null;
targetType: string;
targetUserId: bigint | null;
targetUsername: string | null;
recipientCount: number;
createdById: bigint | null;
createdByUsername: string | null;
createdAt: Date;
}) {
const translations = parseBroadcastTranslations(row.translations);
const hasTranslations = Object.keys(translations).length > 0;
const preview =
resolveBroadcastContent(hasTranslations ? translations : { 'en-US': { title: row.title, body: row.body } }, 'en-US') ??
{ title: row.title, body: row.body };
return {
id: row.id.toString(),
title: preview.title,
body: preview.body,
translations: hasTranslations
? translations
: { 'en-US': { title: row.title, body: row.body } },
targetType: row.targetType,
targetUserId: row.targetUserId?.toString() ?? null,
targetUsername: row.targetUsername,
recipientCount: row.recipientCount,
createdById: row.createdById?.toString() ?? null,
createdByUsername: row.createdByUsername,
createdAt: row.createdAt.toISOString(),
};
}
@Injectable()
export class PlayerMessagesService {
constructor(private prisma: PrismaService) {}
private buildDepositMessage(
type: Exclude<PlayerMessageType, 'BANNER_PROMO' | 'ANNOUNCEMENT_PROMO' | 'ADMIN_CUSTOM'>,
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 };
}
async listBroadcasts(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 [rows, total] = await Promise.all([
this.prisma.playerMessageBroadcast.findMany({
orderBy: { createdAt: 'desc' },
skip,
take: safePageSize,
}),
this.prisma.playerMessageBroadcast.count(),
]);
return {
items: rows.map(mapBroadcastRow),
total,
page: safePage,
pageSize: safePageSize,
};
}
async createCustomBroadcast(data: {
translations: BroadcastTranslationInput[];
targetType: 'ALL' | 'USER';
targetUsername?: string | null;
createdById?: bigint | null;
createdByUsername?: string | null;
}) {
const translations = normalizeBroadcastTranslations(data.translations);
const preview = resolveBroadcastContent(translations, 'en-US');
if (!preview?.title && !preview?.body) {
throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
}
const title = preview.title.slice(0, 256);
const body = preview.body;
if (!title && !body) throw appBadRequest('BROADCAST_CONTENT_REQUIRED');
let targetUsers: Array<{
id: bigint;
username: string;
locale: string | null;
preferences: { locale: string | null } | null;
}> = [];
let targetUserId: bigint | null = null;
let targetUsername: string | null = null;
if (data.targetType === 'ALL') {
targetUsers = await this.prisma.user.findMany({
where: { userType: 'PLAYER', deletedAt: null, status: 'ACTIVE' },
select: {
id: true,
username: true,
locale: true,
preferences: { select: { locale: true } },
},
});
} else {
const username = data.targetUsername?.trim();
if (!username) throw appBadRequest('BROADCAST_TARGET_USER_REQUIRED');
const player = await this.prisma.user.findFirst({
where: {
username,
userType: 'PLAYER',
deletedAt: null,
status: 'ACTIVE',
},
select: {
id: true,
username: true,
locale: true,
preferences: { select: { locale: true } },
},
});
if (!player) throw appNotFound('PLAYER_NOT_FOUND');
targetUsers = [player];
targetUserId = player.id;
targetUsername = player.username;
}
if (!targetUsers.length) throw appBadRequest('BROADCAST_NO_RECIPIENTS');
const broadcast = await this.prisma.$transaction(async (tx) => {
const created = await tx.playerMessageBroadcast.create({
data: {
title,
body,
translations: translations as Prisma.InputJsonValue,
targetType: data.targetType,
targetUserId,
targetUsername,
recipientCount: targetUsers.length,
createdById: data.createdById ?? null,
createdByUsername: data.createdByUsername ?? null,
},
});
const rows = targetUsers.map((player) => {
const playerLocale = player.preferences?.locale ?? player.locale;
const content =
resolveBroadcastContent(translations, playerLocale) ?? preview;
return {
userId: player.id,
type: 'ADMIN_CUSTOM',
title: content.title.slice(0, 256),
body: content.body,
broadcastId: created.id,
payload: { broadcastId: created.id.toString() } as Prisma.InputJsonValue,
};
});
const batchSize = 200;
for (let i = 0; i < rows.length; i += batchSize) {
await tx.playerMessage.createMany({ data: rows.slice(i, i + batchSize) });
}
return created;
});
return mapBroadcastRow(broadcast);
}
async deleteBroadcast(broadcastId: bigint) {
const row = await this.prisma.playerMessageBroadcast.findUnique({
where: { id: broadcastId },
});
if (!row) throw appNotFound('BROADCAST_NOT_FOUND');
await this.prisma.playerMessageBroadcast.delete({ where: { id: broadcastId } });
return { deleted: true, recipientCount: row.recipientCount };
}
}

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

@@ -118,8 +118,8 @@ function settleOverUnder(
if (winCount === 2) return 'WIN';
if (loseCount === 2) return 'LOSE';
if (winCount === 1) return 'HALF_WIN';
if (loseCount === 1) return 'HALF_LOSE';
if (winCount === 1 && loseCount === 0) return 'HALF_WIN';
if (loseCount === 1 && winCount === 0) return 'HALF_LOSE';
return 'PUSH';
}

View File

@@ -148,7 +148,9 @@ describe('SettlementService outright winner flow', () => {
settlementBatch: {
create: settlementBatchCreate,
findUnique: settlementBatchFindUnique,
findFirst: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
bet: { findMany: betFindMany },
$transaction: transaction,
@@ -247,7 +249,7 @@ describe('SettlementService outright winner flow', () => {
);
});
it('keeps a parlay pending until every match in the ticket is settled', async () => {
it('shows parlay as lost when this match leg loses even if other legs are pending', async () => {
matchFindFirst.mockResolvedValue({
id: matchId,
isOutright: false,
@@ -304,14 +306,13 @@ describe('SettlementService outright winner flow', () => {
ftAway: 1,
});
expect(preview.pendingOtherMatches).toBe(1);
expect(preview.lostOnThisMatch).toBe(0);
expect(preview.pendingOtherMatches).toBe(0);
expect(preview.lostOnThisMatch).toBe(1);
expect(preview.items.items).toEqual([
expect.objectContaining({
betNo: 'PARLAY-PENDING',
result: 'PENDING_OTHER_MATCHES',
result: 'LOST',
payout: '0',
note: '本场腿已出结果,待其他场次结算后统一结算',
}),
]);
});
@@ -404,6 +405,7 @@ describe('SettlementService outright winner flow', () => {
findUnique: settlementBatchFindUnique,
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
},
match: { update: txMatchUpdate },
});
@@ -474,3 +476,442 @@ describe('SettlementService outright winner flow', () => {
expect(result).toEqual({ success: true, batchId: batchId.toString() });
});
});
describe('SettlementService hardening', () => {
const matchId = BigInt(200);
const operatorId = BigInt(1);
const batchId = BigInt(900);
const otherBatchId = BigInt(901);
const fixtureMatch = {
id: matchId,
isOutright: false,
status: 'PENDING_SETTLEMENT',
deletedAt: null,
};
const pendingSingleBet = {
id: BigInt(3001),
betNo: 'BET-SINGLE',
betType: 'SINGLE',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(70),
user: { id: BigInt(70) },
selections: [
{
id: BigInt(4001),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 0,
},
],
};
function buildService(overrides: {
prisma?: Record<string, unknown>;
wallet?: Record<string, jest.Mock>;
transactionClient?: Record<string, unknown>;
} = {}) {
const wallet = {
settleBet: jest.fn().mockResolvedValue(undefined),
voidBet: jest.fn().mockResolvedValue(undefined),
...(overrides.wallet ?? {}),
};
const agents = { recalculateUsedCredit: jest.fn().mockResolvedValue(undefined) };
const transaction = jest.fn(async (fn: (client: unknown) => Promise<void>) => {
const defaultClient = {
team: { findUnique: jest.fn().mockResolvedValue(null) },
market: { findMany: jest.fn().mockResolvedValue([]) },
marketSelection: { findMany: jest.fn().mockResolvedValue([{ id: BigInt(501), selectionCode: 'HOME' }]) },
matchScore: {
upsert: jest.fn().mockResolvedValue({}),
findUnique: jest.fn().mockResolvedValue({
matchId,
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 1,
ftAwayScore: 0,
winnerTeamId: null,
}),
},
bet: {
findMany: jest.fn().mockResolvedValue([pendingSingleBet]),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
},
betSelection: { update: jest.fn().mockResolvedValue({}), findMany: jest.fn().mockResolvedValue([]) },
settlementItem: { create: jest.fn().mockResolvedValue({}) },
settlementBatch: {
findUnique: jest.fn().mockResolvedValue({
id: batchId,
batchNo: 'STL-001',
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 1,
ftAwayScore: 0,
match: fixtureMatch,
}),
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
},
match: { update: jest.fn().mockResolvedValue({}) },
...(overrides.transactionClient ?? {}),
};
await fn(defaultClient);
});
const prisma = {
match: {
count: jest.fn().mockResolvedValue(0),
findFirst: jest.fn().mockResolvedValue({ ...fixtureMatch, status: 'CLOSED' }),
update: jest.fn().mockResolvedValue({}),
},
team: { findUnique: jest.fn().mockResolvedValue(null) },
market: { findMany: jest.fn().mockResolvedValue([]) },
marketSelection: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([{ id: BigInt(501), selectionCode: 'HOME' }]),
},
matchScore: {
findUnique: jest.fn().mockResolvedValue(null),
upsert: jest.fn().mockResolvedValue({}),
},
settlementBatch: {
create: jest.fn().mockResolvedValue({ id: batchId, matchId, status: 'PREVIEW' }),
findUnique: jest.fn(),
findFirst: jest.fn().mockResolvedValue(null),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
bet: { findMany: jest.fn().mockResolvedValue([]) },
$transaction: transaction,
...(overrides.prisma ?? {}),
};
return {
service: new SettlementService(prisma as never, wallet as never, agents as never),
prisma,
wallet,
transaction,
};
}
it('recordScore rejects half-time scores greater than full-time', async () => {
const { service } = buildService();
try {
await service.recordScore(matchId, 2, 0, 1, 0, operatorId);
throw new Error('Expected recordScore to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({ code: 'SETTLEMENT_SCORE_INVALID' }),
);
}
});
it('previewSettlement cancels older preview batches before creating a new one', async () => {
const updateMany = jest.fn().mockResolvedValue({ count: 1 });
const { service } = buildService({
prisma: {
bet: { findMany: jest.fn().mockResolvedValue([]) },
settlementBatch: {
create: jest.fn().mockResolvedValue({ id: batchId, matchId, status: 'PREVIEW' }),
updateMany,
},
},
});
await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 0,
});
expect(updateMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { matchId, status: 'PREVIEW', isResettle: false },
data: { status: 'CANCELLED' },
}),
);
});
it('confirmSettlement rejects stale preview batches', async () => {
const staleBatch = {
id: otherBatchId,
batchNo: 'STL-OLD',
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 1,
ftAwayScore: 0,
match: fixtureMatch,
};
const { service } = buildService({
prisma: {
settlementBatch: {
findUnique: jest.fn().mockResolvedValue(staleBatch),
},
},
transactionClient: {
settlementBatch: {
findUnique: jest.fn().mockResolvedValue(staleBatch),
update: jest.fn().mockResolvedValue({}),
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
findFirst: jest.fn().mockResolvedValue({ id: batchId }),
},
},
});
try {
await service.confirmSettlement(otherBatchId, operatorId);
throw new Error('Expected confirmSettlement to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({ code: 'SETTLEMENT_BATCH_STALE' }),
);
}
});
it('confirmSettlement throws when bet status update fails', async () => {
const { service } = buildService({
prisma: {
settlementBatch: {
findUnique: jest.fn().mockResolvedValue({
id: batchId,
batchNo: 'STL-001',
matchId,
status: 'PREVIEW',
htHomeScore: 0,
htAwayScore: 0,
ftHomeScore: 1,
ftAwayScore: 0,
match: fixtureMatch,
}),
},
},
transactionClient: {
bet: {
findMany: jest.fn().mockResolvedValue([pendingSingleBet]),
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
},
},
});
try {
await service.confirmSettlement(batchId, operatorId);
throw new Error('Expected confirmSettlement to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({
code: 'SETTLEMENT_BET_UPDATE_FAILED',
params: expect.objectContaining({ betNo: 'BET-SINGLE' }),
}),
);
}
});
it('voidMatchBets settles cross-match parlay as lost when voided leg joins an existing losing leg', async () => {
const parlayBet = {
id: BigInt(5001),
betNo: 'PARLAY-VOID',
betType: 'PARLAY',
status: 'PENDING',
stake: new Decimal(100),
agentId: null,
userId: BigInt(80),
selections: [
{
id: BigInt(6001),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: null,
sortOrder: 0,
},
{
id: BigInt(6002),
matchId: BigInt(201),
marketType: 'FT_1X2',
selectionId: BigInt(502),
selectionNameSnapshot: 'Away',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: 'LOSE',
sortOrder: 1,
},
],
};
const betSelectionUpdate = jest.fn().mockResolvedValue({});
const betSelectionFindMany = jest
.fn()
.mockResolvedValue([
{ ...parlayBet.selections[0], resultStatus: 'VOID', odds: new Decimal(2) },
{ ...parlayBet.selections[1], resultStatus: 'LOSE', odds: new Decimal(2) },
]);
const betUpdateMany = jest.fn().mockResolvedValue({ count: 1 });
const funds = { settleBet: jest.fn().mockResolvedValue(undefined), voidBet: jest.fn() };
const transaction = jest.fn(async (fn: (client: unknown) => Promise<unknown>) =>
fn({
match: { update: jest.fn().mockResolvedValue({}) },
bet: {
findMany: jest.fn().mockResolvedValue([parlayBet]),
updateMany: betUpdateMany,
},
betSelection: {
update: betSelectionUpdate,
findMany: betSelectionFindMany,
},
}),
);
const service = new SettlementService(
{ $transaction: transaction } as never,
funds as never,
{ recalculateUsedCredit: jest.fn() } as never,
);
const result = await service.voidMatchBets(matchId);
expect(result.voidedCount).toBe(1);
expect(funds.voidBet).not.toHaveBeenCalled();
expect(funds.settleBet).toHaveBeenCalledWith(
expect.objectContaining({
betNo: 'PARLAY-VOID',
result: 'LOSE',
batchNo: `void:${matchId}`,
}),
);
expect(funds.settleBet.mock.calls[0][0].payout.toString()).toBe('0');
});
it('previewResettlement treats single multi-leg tickets like parlays', async () => {
const multiLegSingle = {
id: BigInt(7001),
betNo: 'SINGLE-MULTI',
betType: 'SINGLE',
status: 'WON',
stake: new Decimal(100),
actualReturn: new Decimal(360),
agentId: null,
userId: BigInt(90),
selections: [
{
id: BigInt(8001),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(501),
selectionNameSnapshot: 'Home',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: 'WIN',
sortOrder: 0,
},
{
id: BigInt(8002),
matchId,
marketType: 'FT_1X2',
selectionId: BigInt(502),
selectionNameSnapshot: 'Away',
handicapLine: null,
totalLine: null,
odds: new Decimal(2),
resultStatus: 'WIN',
sortOrder: 1,
},
],
};
const { service } = buildService({
prisma: {
match: {
count: jest.fn().mockResolvedValue(0),
findFirst: jest.fn().mockResolvedValue({ ...fixtureMatch, status: 'SETTLED' }),
update: jest.fn().mockResolvedValue({}),
},
bet: { findMany: jest.fn().mockResolvedValue([multiLegSingle]) },
settlementBatch: {
create: jest.fn().mockImplementation(({ data }) =>
Promise.resolve({ id: batchId, ...data }),
),
},
},
});
const preview = await service.previewResettlement(
matchId,
{
htHome: 0,
htAway: 0,
ftHome: 0,
ftAway: 1,
},
operatorId,
);
expect(preview.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
betNo: 'SINGLE-MULTI',
newStatus: 'LOST',
}),
]),
);
expect(preview.items[0].newPayout.toString()).toBe('0');
});
it('previewSettlement rejects unsupported market types on pending bets', async () => {
const { service } = buildService({
prisma: {
bet: {
findMany: jest.fn().mockResolvedValue([
{
...pendingSingleBet,
selections: [
{
...pendingSingleBet.selections[0],
marketType: 'UNKNOWN_MARKET',
},
],
},
]),
},
},
});
try {
await service.previewSettlement(matchId, operatorId, {
htHome: 0,
htAway: 0,
ftHome: 1,
ftAway: 0,
});
throw new Error('Expected previewSettlement to reject');
} catch (err) {
const response = (err as { getResponse?: () => unknown }).getResponse?.();
expect(response).toEqual(
expect.objectContaining({ code: 'SETTLEMENT_MARKET_UNSUPPORTED' }),
);
}
});
});

View File

@@ -18,6 +18,7 @@ import {
resolveSelectionCode,
templateScoresForMarket,
} from './domain/settlement-helpers';
import { isSettlementSupportedMarketType } from '@thebet365/shared';
const SETTLEMENT_ENTRY_STATUSES = new Set(['CLOSED', 'PENDING_SETTLEMENT', 'SETTLED']);
const STAT_MARKET_REQUIREMENTS = {
@@ -189,6 +190,95 @@ export class SettlementService {
}
}
private assertScoreConsistency(score: ScoreInput, isOutright: boolean) {
if (isOutright) return;
if (
score.htHome < 0 ||
score.htAway < 0 ||
score.ftHome < 0 ||
score.ftAway < 0 ||
score.htHome > score.ftHome ||
score.htAway > score.ftAway
) {
throw appBadRequest('SETTLEMENT_SCORE_INVALID');
}
}
private assertSupportedMarketTypes(
bets: Array<{ selections: Array<{ marketType: string }> }>,
) {
for (const bet of bets) {
for (const sel of bet.selections) {
if (!isSettlementSupportedMarketType(sel.marketType)) {
throw appBadRequest('SETTLEMENT_MARKET_UNSUPPORTED', {
marketType: sel.marketType,
});
}
}
}
}
private assertOutrightWinnerForBets(
bets: Array<{ selections: Array<{ marketType: string }> }>,
winnerTeamCode: string | null,
isOutright: boolean,
) {
const hasOutrightLeg =
isOutright ||
bets.some((bet) =>
bet.selections.some((sel) => sel.marketType === 'OUTRIGHT_WINNER'),
);
if (hasOutrightLeg && !winnerTeamCode) {
throw appBadRequest('SETTLEMENT_WINNER_REQUIRED');
}
}
private requireBetUpdate(updated: { count: number }, betNo: string) {
if (updated.count !== 1) {
throw appBadRequest('SETTLEMENT_BET_UPDATE_FAILED', { betNo });
}
}
private async assertLatestPreviewBatch(
batchId: bigint,
matchId: bigint,
tx?: TxClient,
) {
const client: PrismaClientLike = tx ?? this.prisma;
const latest = await client.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW', isResettle: false },
orderBy: { createdAt: 'desc' },
select: { id: true },
});
if (latest && latest.id !== batchId) {
throw appBadRequest('SETTLEMENT_BATCH_STALE', { batchId: batchId.toString() });
}
}
private async cancelStalePreviewBatches(matchId: bigint, tx?: TxClient) {
const client: PrismaClientLike = tx ?? this.prisma;
await client.settlementBatch.updateMany({
where: { matchId, status: 'PREVIEW', isResettle: false },
data: { status: 'CANCELLED' },
});
}
private parlayBetStatusFromResult(
parlayResult: ReturnType<typeof calculateParlayPayout>,
): 'LOST' | 'PUSH' | 'WON' {
if (parlayResult.betResult === 'LOST') return 'LOST';
if (parlayResult.betResult === 'PUSH') return 'PUSH';
return 'WON';
}
private walletResultFromParlayBetResult(
betResult: 'WON' | 'LOST' | 'PUSH',
): 'WIN' | 'LOSE' | 'PUSH' {
if (betResult === 'LOST') return 'LOSE';
if (betResult === 'PUSH') return 'PUSH';
return 'WIN';
}
async recordScore(
matchId: bigint,
htHome: number,
@@ -226,6 +316,13 @@ export class SettlementService {
}
}
if (!match.isOutright) {
this.assertScoreConsistency(
{ htHome, htAway, ftHome, ftAway },
match.isOutright,
);
}
const stats = this.statsInputFromSource(statsInput);
await this.prisma.matchScore.upsert({
where: { matchId },
@@ -312,7 +409,19 @@ export class SettlementService {
}
const scoreSource = await this.resolvePreviewScoreSource(matchId, match.isOutright, opts);
if (!match.isOutright) {
this.assertScoreConsistency(
{
htHome: scoreSource.htHome,
htAway: scoreSource.htAway,
ftHome: scoreSource.ftHome,
ftAway: scoreSource.ftAway,
},
match.isOutright,
);
}
const computation = await this.computePreviewComputation(matchId, scoreSource);
await this.cancelStalePreviewBatches(matchId);
const batch = await this.prisma.settlementBatch.create({
data: {
matchId,
@@ -388,6 +497,83 @@ export class SettlementService {
};
}
async getActivePreview(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const batch = await this.prisma.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW' },
orderBy: { createdAt: 'desc' },
});
if (!batch) return null;
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId },
});
const computation = await this.computePreviewComputation(matchId, {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
homeCorners: batch.homeCorners ?? null,
awayCorners: batch.awayCorners ?? null,
homeYellowCards: batch.homeYellowCards ?? null,
awayYellowCards: batch.awayYellowCards ?? null,
homeRedCards: batch.homeRedCards ?? null,
awayRedCards: batch.awayRedCards ?? null,
homeCards: batch.homeCards ?? null,
awayCards: batch.awayCards ?? null,
winnerTeamId: existingScore?.winnerTeamId ?? null,
});
return this.buildPreviewResponse(computation, batch, opts);
}
async getMatchSettlementHistory(matchId: bigint) {
const batches = await this.prisma.settlementBatch.findMany({
where: { matchId, status: 'CONFIRMED' },
orderBy: { confirmedAt: 'desc' },
});
const operatorIds = batches
.map((b) => b.operatorId)
.filter((id): id is bigint => id !== null);
const operators =
operatorIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, username: true },
})
: [];
const operatorMap = new Map(operators.map((o) => [o.id.toString(), o.username]));
return batches.map((b) => ({
id: b.id.toString(),
batchNo: b.batchNo,
htHomeScore: b.htHomeScore,
htAwayScore: b.htAwayScore,
ftHomeScore: b.ftHomeScore,
ftAwayScore: b.ftAwayScore,
homeCorners: b.homeCorners,
awayCorners: b.awayCorners,
homeYellowCards: b.homeYellowCards,
awayYellowCards: b.awayYellowCards,
homeRedCards: b.homeRedCards,
awayRedCards: b.awayRedCards,
homeCards: b.homeCards,
awayCards: b.awayCards,
totalBets: b.totalBets,
totalPayout: b.totalPayout.toString(),
totalRefund: b.totalRefund.toString(),
confirmedAt: b.confirmedAt?.toISOString() ?? null,
isResettle: b.isResettle,
reason: b.reason,
operatorUsername: b.operatorId ? operatorMap.get(b.operatorId.toString()) ?? '—' : '—',
}));
}
private buildPreviewResponse(
computation: {
scoreInput: ScoreInput;
@@ -656,6 +842,8 @@ export class SettlementService {
statsInput,
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
);
this.assertSupportedMarketTypes(pendingBets);
this.assertOutrightWinnerForBets(pendingBets, winnerTeamCode, false);
let totalPayout = new Decimal(0);
let totalRefund = new Decimal(0);
@@ -681,6 +869,7 @@ export class SettlementService {
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
const payout = calculatePayout(bet.stake, sel.odds, result);
if (result === 'WIN' || result === 'HALF_WIN') wonLegsOnMatch += 1;
if (result === 'LOSE') lostOnThisMatch += 1;
items.push({ betId: bet.id, betNo: bet.betNo, betType: 'SINGLE', result, payout });
if (result === 'PUSH' || result === 'VOID') {
totalRefund = totalRefund.add(bet.stake);
@@ -699,6 +888,7 @@ export class SettlementService {
};
});
const parlay = calculateParlayPayout(bet.stake, legResults);
if (parlay.betResult === 'LOST') lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
@@ -732,6 +922,7 @@ export class SettlementService {
selectionCodes,
);
if (preview.kind === 'SETTLED') {
if (preview.betResult === 'LOST') lostOnThisMatch += 1;
items.push({
betId: bet.id,
betNo: bet.betNo,
@@ -788,11 +979,18 @@ export class SettlementService {
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const legResult = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
if (legResult === 'LOSE') {
return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
}
legResults.push({
odds: sel.odds,
result: this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode),
result: legResult,
});
} else if (sel.resultStatus) {
if (sel.resultStatus === 'LOSE') {
return { kind: 'SETTLED', betResult: 'LOST', payout: new Decimal(0) };
}
legResults.push({
odds: sel.odds,
result: sel.resultStatus as SelectionResult,
@@ -889,6 +1087,13 @@ export class SettlementService {
pendingBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
tx,
);
this.assertSupportedMarketTypes(pendingBets);
this.assertOutrightWinnerForBets(
pendingBets,
winnerTeamCode,
currentBatch.match.isOutright,
);
await this.assertLatestPreviewBatch(batchId, currentBatch.matchId, tx);
let settledCount = 0;
await this.upsertMatchScoreRecord(
@@ -924,7 +1129,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await tx.betSelection.update({
where: { id: sel.id },
@@ -968,12 +1173,7 @@ export class SettlementService {
});
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
@@ -983,7 +1183,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await this.funds.settleBet({
userId: bet.userId,
@@ -991,12 +1191,7 @@ export class SettlementService {
payout: parlayResult.payout,
betNo: bet.betNo,
batchNo: batch.batchNo,
result:
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
@@ -1039,12 +1234,7 @@ export class SettlementService {
result: s.resultStatus as SelectionResult,
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
@@ -1054,7 +1244,7 @@ export class SettlementService {
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
this.requireBetUpdate(updatedBet, bet.betNo);
await this.funds.settleBet({
userId: bet.userId,
@@ -1062,12 +1252,7 @@ export class SettlementService {
payout: parlayResult.payout,
betNo: bet.betNo,
batchNo: batch.batchNo,
result:
parlayResult.betResult === 'LOST'
? 'LOSE'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WIN',
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
@@ -1309,7 +1494,7 @@ export class SettlementService {
winnerTeamCode: string | null,
selectionCodes: Map<string, string | null>,
) {
if (bet.betType === 'SINGLE') {
if (bet.betType === 'SINGLE' && bet.selections.length === 1) {
const sel = bet.selections[0];
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
@@ -1324,6 +1509,26 @@ export class SettlementService {
};
}
if (bet.betType === 'SINGLE' && bet.selections.length > 1) {
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
const legUpdates = new Map<string, SelectionResult>();
for (const sel of bet.selections) {
const code = resolveSelectionCode(
selectionCodes.get(sel.selectionId.toString()),
sel.selectionNameSnapshot,
);
const result = this.settleLegResult(sel, code, scoreInput, statsInput, winnerTeamCode);
legResults.push({ odds: sel.odds, result });
legUpdates.set(sel.id.toString(), result);
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
return {
payout: parlayResult.payout,
betStatus: this.parlayBetStatusFromResult(parlayResult),
legUpdates,
};
}
const legResults: Array<{ odds: Decimal; result: SelectionResult }> = [];
const legUpdates = new Map<string, SelectionResult>();
@@ -1346,14 +1551,11 @@ export class SettlementService {
}
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus =
parlayResult.betResult === 'LOST'
? 'LOST'
: parlayResult.betResult === 'PUSH'
? 'PUSH'
: 'WON';
return { payout: parlayResult.payout, betStatus, legUpdates };
return {
payout: parlayResult.payout,
betStatus: this.parlayBetStatusFromResult(parlayResult),
legUpdates,
};
}
async previewResettlement(
@@ -1371,6 +1573,18 @@ export class SettlementService {
throw appBadRequest('RESETTLE_SETTLED_ONLY');
}
if (!match.isOutright) {
this.assertScoreConsistency(
{
htHome: scoreInput.htHome,
htAway: scoreInput.htAway,
ftHome: scoreInput.ftHome,
ftAway: scoreInput.ftAway,
},
match.isOutright,
);
}
const winnerTeamCode = winnerTeamId
? await this.resolveWinnerTeamCode(winnerTeamId)
: null;
@@ -1390,6 +1604,8 @@ export class SettlementService {
statsInput,
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
);
this.assertSupportedMarketTypes(settledBets);
this.assertOutrightWinnerForBets(settledBets, winnerTeamCode, match.isOutright);
const items: Array<{
betId: bigint;
betNo: string;
@@ -1535,6 +1751,12 @@ export class SettlementService {
settledBets.flatMap((bet) => bet.selections.map((sel) => sel.marketType)),
tx,
);
this.assertSupportedMarketTypes(settledBets);
this.assertOutrightWinnerForBets(
settledBets,
winnerTeamCode,
currentBatch.match.isOutright,
);
let affectedCount = 0;
await this.upsertMatchScoreRecord(
@@ -1638,6 +1860,7 @@ export class SettlementService {
options: { cancelMatch?: boolean } = {},
) {
const agentIds = new Set<bigint>();
const voidBatchNo = `void:${matchId}`;
const voidedCount = await this.prisma.$transaction(async (tx) => {
if (options.cancelMatch) {
@@ -1649,21 +1872,82 @@ export class SettlementService {
const bets = await tx.bet.findMany({
where: { status: 'PENDING', selections: { some: { matchId } } },
include: { selections: { orderBy: { sortOrder: 'asc' } } },
});
let count = 0;
for (const bet of bets) {
const updated = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
if (updated.count !== 1) continue;
const isSingleOneLeg = bet.betType === 'SINGLE' && bet.selections.length === 1;
await this.funds.voidBet({
if (isSingleOneLeg) {
const sel = bet.selections[0];
const updated = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: { status: 'VOID', actualReturn: bet.stake, settledAt: new Date() },
});
if (updated.count !== 1) continue;
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: 'VOID' },
});
await this.funds.voidBet({
userId: bet.userId,
stake: bet.stake,
betNo: bet.betNo,
businessKey: `void:${matchId}:${bet.betNo}`,
tx,
});
if (bet.agentId) agentIds.add(bet.agentId);
count += 1;
continue;
}
const legsOnMatch = bet.selections.filter(
(sel) => sel.matchId?.toString() === matchId.toString(),
);
if (!legsOnMatch.length) continue;
for (const sel of legsOnMatch) {
await tx.betSelection.update({
where: { id: sel.id },
data: { resultStatus: 'VOID' },
});
}
const updatedLegs = await tx.betSelection.findMany({
where: { betId: bet.id },
orderBy: { sortOrder: 'asc' },
});
if (!updatedLegs.every((sel) => sel.resultStatus != null)) {
continue;
}
const legResults = updatedLegs.map((sel) => ({
odds: sel.odds,
result: sel.resultStatus as SelectionResult,
}));
const parlayResult = calculateParlayPayout(bet.stake, legResults);
const betStatus = this.parlayBetStatusFromResult(parlayResult);
const updatedBet = await tx.bet.updateMany({
where: { id: bet.id, status: 'PENDING' },
data: {
status: betStatus,
actualReturn: parlayResult.payout,
settledAt: new Date(),
},
});
if (updatedBet.count !== 1) continue;
await this.funds.settleBet({
userId: bet.userId,
stake: bet.stake,
payout: parlayResult.payout,
betNo: bet.betNo,
businessKey: `void:${matchId}:${bet.betNo}`,
batchNo: voidBatchNo,
result: this.walletResultFromParlayBetResult(parlayResult.betResult),
tx,
});
if (bet.agentId) agentIds.add(bet.agentId);

View File

@@ -9,6 +9,21 @@ 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;
/** 发布 Banner 内容时发送站内信推广 */
banner: boolean;
/** 发布公告/滚动条内容时发送站内信推广 */
announcement: boolean;
};
export type PlatformDirectCashbackSettings = {
/** 平台直属玩家默认返水比例小数0.01 = 1% */
@@ -30,9 +45,9 @@ export type PlayerAccountSettings = {
};
export type AgentSuspendSettings = {
/** 停用代理时是否允许级联冻结直属玩家(需管理员显式勾选 */
/** 停用代理时默认级联冻结直属玩家(单次操作仍可覆盖 */
suspendFreezeDirectPlayers: boolean;
/** 上级代理停用时是否禁止直属玩家登录 */
/** 停用代理时默认禁止直属玩家登录(单次操作仍可覆盖) */
suspendBlockPlayerLogin: boolean;
};
@@ -222,4 +237,77 @@ export class SystemConfigService {
}
return this.getPlatformDirectCashbackSettings();
}
async getInboxFeatureEnabled(): Promise<boolean> {
return this.getBoolean(INBOX_FEATURE_ENABLED, true);
}
async getInboxNotifySettings(): Promise<InboxNotifySettings> {
const [inboxEnabled, deposit, banner, announcement] = await Promise.all([
this.getBoolean(INBOX_FEATURE_ENABLED, true),
this.getBoolean(INBOX_NOTIFY_DEPOSIT, true),
this.getBoolean(INBOX_NOTIFY_BANNER, true),
this.getBoolean(INBOX_NOTIFY_ANNOUNCEMENT, true),
]);
return { inboxEnabled, deposit, banner, announcement };
}
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,
'充值审核结果是否通过站内邮箱通知玩家',
);
}
if (data.banner !== undefined) {
await this.setBoolean(
INBOX_NOTIFY_BANNER,
data.banner,
'发布 Banner 内容时是否发送站内信推广',
);
}
if (data.announcement !== undefined) {
await this.setBoolean(
INBOX_NOTIFY_ANNOUNCEMENT,
data.announcement,
'发布公告/滚动条内容时是否发送站内信推广',
);
}
return this.getInboxNotifySettings();
}
async getDepositScreenshotCleanupConfig(): Promise<{ enabled: boolean; keepDays: number }> {
const enabled = await this.getBoolean('deposit.cleanup.enabled', false);
const keepDays = await this.getInt('deposit.cleanup.keep_days', 180);
return { enabled, keepDays };
}
async updateDepositScreenshotCleanupConfig(data: { enabled?: boolean; keepDays?: number }) {
if (data.enabled !== undefined) {
await this.setBoolean(
'deposit.cleanup.enabled',
data.enabled,
'是否开启定时清理充值截图',
);
}
if (data.keepDays !== undefined) {
if (!Number.isInteger(data.keepDays) || data.keepDays <= 0) {
throw new Error('keepDays must be a positive integer');
}
await this.setInt(
'deposit.cleanup.keep_days',
data.keepDays,
'充值截图保留天数',
);
}
return this.getDepositScreenshotCleanupConfig();
}
}

View File

@@ -2,9 +2,14 @@
<html lang="zh-CN">
<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="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="TheBet365" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/logo.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>TheBet365</title>

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,11 +41,12 @@ 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;
@@ -39,6 +56,11 @@ const text = computed(() => {
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 {
@@ -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

@@ -53,7 +53,7 @@ const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl'));
}
.back-top-btn.above-nav {
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
bottom: calc(var(--player-bottom-nav-h, 54px) + var(--safe-bottom, env(safe-area-inset-bottom, 0px)) + 12px);
}
.back-top-btn:active {

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);
}
}
@@ -201,6 +203,8 @@ onUnmounted(stopAutoPlay);
linear-gradient(135deg, rgba(7, 18, 31, 0.94), rgba(6, 8, 12, 0.98)),
#070d15;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.slide::after {

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 {
@@ -37,6 +37,27 @@ 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 activeItems = computed<SlipItem[]>(() => {
if (activeTab.value === 'parlay') return slip.parlayItems;
@@ -44,22 +65,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 +270,81 @@ 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';
}
async function placeBet() {
if (!activeItems.value.length) return;
if (!auth.token) {
@@ -234,6 +365,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 +417,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 +430,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 +474,8 @@ watch(
</button>
</div>
<p v-if="oddsWarningText" class="odds-warning">{{ oddsWarningText }}</p>
<div class="slip-tabs">
<button
type="button"
@@ -354,8 +509,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 +538,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,10 +621,10 @@ 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>
@@ -695,6 +875,33 @@ watch(
line-height: 1;
}
.odds-warning {
margin: 0 16px 10px;
padding: 10px 12px;
border-radius: 8px;
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;
}
.odds-change {
font-weight: 700;
}
.odds-change.odds-up {
color: #6ee7a0;
}
.odds-change.odds-down {
color: #ff8b8b;
}
.odds-change.odds-suspended {
color: #ffb84d;
}
.warning,
.error {
margin: 0 0 10px;

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.72);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.confirm-modal {
width: 100%;
max-width: 340px;
background: linear-gradient(165deg, #1a1810 0%, #121212 45%, #0a0a0a 100%);
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.25));
border-radius: 12px;
padding: 22px 18px 16px;
box-shadow: 0 0 24px rgba(212, 175, 55, 0.08);
}
.confirm-title {
margin: 0 0 10px;
font-size: 17px;
font-weight: 800;
color: var(--gold, #c8a84e);
text-align: center;
line-height: 1.35;
}
.confirm-message {
margin: 0 0 20px;
font-size: 14px;
line-height: 1.6;
color: #c8c8c8;
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 #333;
background: transparent;
color: #888;
}
.confirm-btn.confirm {
border: none;
background: linear-gradient(135deg, #d4a017, #e8c84a);
color: #1a1a1a;
}
.confirm-btn.confirm.danger {
background: linear-gradient(135deg, #c0392b, #e74c3c);
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: #0d0d0d;
}
.cs-frame {
flex: 1;
width: 100%;
min-height: calc(100dvh - 140px);
border: 0;
background: #fff;
}
</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

View File

@@ -68,6 +68,7 @@ onUnmounted(() => {
<style scoped>
.locale-switch {
position: relative;
z-index: 120;
display: inline-flex;
flex-shrink: 0;
}
@@ -100,7 +101,7 @@ onUnmounted(() => {
position: absolute;
top: calc(100% + 4px);
right: 0;
z-index: 50;
z-index: 130;
min-width: 100%;
margin: 0;
padding: 4px;

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>

View File

@@ -0,0 +1,347 @@
<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';
import { stripHtml } from '../utils/html';
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;
}
const plain = stripHtml(item.body);
return plain.length > 80 ? `${plain.slice(0, 80)}` : plain;
}
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-gold-soft);
border-radius: 8px;
padding: 8px 14px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
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: #fff;
font-weight: 700;
}
.row-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: transparent;
flex-shrink: 0;
}
.list-row.unread .row-dot {
background: var(--gold);
box-shadow: 0 0 8px rgba(212, 175, 55, 0.45);
}
.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: #d8d8d8;
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: #888;
background: rgba(255, 255, 255, 0.06);
}
.status-badge.unread {
color: var(--gold);
background: rgba(212, 175, 55, 0.12);
}
.preview {
font-size: 13px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.date {
font-size: 12px;
color: #777;
}
.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: #666;
cursor: pointer;
}
.delete-btn:active:not(:disabled) {
background: rgba(255, 80, 80, 0.12);
color: #f66;
}
.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: #141414;
color: var(--gold);
font-size: 13px;
}
</style>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { formatMoneyCompact, parseAmount } from '../utils/localeDisplay';
import { parseAmount, formatMoneyCompact } from '../utils/localeDisplay';
interface Transaction {
transactionType: string;
amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
transactionId?: string;
}

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>
@@ -86,6 +87,17 @@ const { t } = useI18n();
border: 1px solid rgba(255, 184, 0, 0.35);
}
.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);
}
@@ -95,16 +107,4 @@ const { t } = useI18n();
color: var(--text-muted);
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>
@@ -301,6 +451,32 @@ function formatOdds(odds: string) {
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);

View File

@@ -14,6 +14,7 @@ export interface OutrightSelection {
logoUrl?: string | null;
odds: string;
oddsVersion: string;
isWinner?: boolean;
}
export interface OutrightEvent {
@@ -22,6 +23,8 @@ export interface OutrightEvent {
leagueCode?: string;
leagueName: string;
title: string;
status?: string;
bettingOpen?: boolean;
selectionCount?: number;
selections: OutrightSelection[];
}
@@ -47,16 +50,21 @@ const headMeta = computed(() => {
const total = props.event.selectionCount ?? props.event.selections.length;
return t('bet.outright_teams_count', { n: total });
});
const isSettled = computed(() => props.event.bettingOpen === false || props.event.status === 'SETTLED');
</script>
<template>
<section class="event-block">
<button type="button" class="event-head" :class="{ 'is-expanded': expanded }" :aria-expanded="expanded" @click="emit('toggle')">
<button type="button" class="event-head" :class="{ 'is-expanded': expanded, 'is-settled': isSettled }" :aria-expanded="expanded" @click="emit('toggle')">
<span class="toggle-icon" :class="{ open: expanded }">
<span class="toggle-mark">{{ expanded ? '' : '+' }}</span>
</span>
<span class="event-head-text">
<span class="event-title">{{ headTitle }}</span>
<span class="event-title-row">
<span class="event-title">{{ headTitle }}</span>
<span v-if="isSettled" class="event-settled-tag">{{ t('bet.outright_settled') }}</span>
</span>
<span v-if="event.leagueName && event.leagueName !== headTitle" class="event-league">
{{ event.leagueName }}
</span>
@@ -74,6 +82,8 @@ const headMeta = computed(() => {
:team-name="sel.teamName"
:logo-url="sel.logoUrl"
:odds="sel.odds"
:disabled="isSettled"
:is-winner="Boolean(sel.isWinner)"
@pick="emit('pick', sel)"
/>
</div>
@@ -149,6 +159,24 @@ const headMeta = computed(() => {
gap: 2px;
}
.event-title-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.event-settled-tag {
flex-shrink: 0;
font-size: 10px;
font-weight: 800;
color: #c9a227;
border: 1px solid rgba(201, 162, 39, 0.45);
border-radius: 999px;
padding: 1px 7px;
line-height: 1.4;
}
.event-title {
font-size: 13px;
font-weight: 800;

View File

@@ -7,6 +7,8 @@ const props = defineProps<{
teamName: string;
odds: string;
logoUrl?: string | null;
disabled?: boolean;
isWinner?: boolean;
}>();
const emit = defineEmits<{ pick: [] }>();
@@ -56,7 +58,14 @@ onUnmounted(() => {
</script>
<template>
<button ref="cardRef" type="button" class="option-card" @click="emit('pick')">
<button
ref="cardRef"
type="button"
class="option-card"
:class="{ 'option-card--disabled': disabled, 'option-card--winner': isWinner }"
:disabled="disabled"
@click="emit('pick')"
>
<img
v-if="imgVisible && flag && !flagFailed"
:src="flag"
@@ -99,6 +108,20 @@ onUnmounted(() => {
border-color: var(--border-gold-soft);
}
.option-card--disabled {
cursor: default;
opacity: 0.72;
}
.option-card--disabled:active {
border-color: rgba(140, 140, 140, 0.35);
}
.option-card--winner {
border-color: rgba(201, 162, 39, 0.75);
box-shadow: 0 0 0 1px rgba(201, 162, 39, 0.25);
}
.flag {
width: 28px;
height: 19px;

View File

@@ -114,6 +114,7 @@ function toggle(id: string) {
}
function openBet(event: OutrightEvent, sel: OutrightSelection) {
if (event.bettingOpen === false || event.status === 'SETTLED') return;
if (!auth.token) {
goLogin();
return;
@@ -144,6 +145,9 @@ function closeModal() {
<p v-if="eventCount > 1" class="panel-summary">
{{ t('bet.outright_events_summary', { events: eventCount, teams: totalSelections }) }}
</p>
<p v-if="events.some((e) => e.bettingOpen === false || e.status === 'SETTLED')" class="panel-settled-hint">
{{ t('bet.outright_settled_hint') }}
</p>
<div class="event-list">
<OutrightEventSection
@@ -182,6 +186,18 @@ function closeModal() {
line-height: 1.4;
}
.panel-settled-hint {
margin: 0 0 12px;
padding: 8px 10px;
border-radius: 8px;
background: rgba(201, 162, 39, 0.08);
border: 1px solid rgba(201, 162, 39, 0.22);
font-size: 12px;
font-weight: 600;
color: #c9a227;
line-height: 1.45;
}
.event-list {
padding-bottom: 8px;
}

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,131 @@
import { ref } from 'vue';
import api from '../api';
export type PlayerMessageType =
| 'DEPOSIT_APPROVED'
| 'DEPOSIT_REJECTED'
| 'BANNER_PROMO'
| 'ANNOUNCEMENT_PROMO'
| 'ADMIN_CUSTOM';
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',
@@ -21,6 +26,67 @@ export default {
banner_slide: 'Slide {n}',
banner_fallback: 'Banner',
},
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',
stake: 'Stake',
@@ -58,6 +124,9 @@ export default {
stats_stake: 'Total Stake',
stats_return: 'Total Return',
cashbacked: 'Cashbacked',
profit: 'Profit',
return_incl_stake: 'Return {amount} (incl. stake)',
stake_to_return: '{stake} × {odds} = {amount}',
},
auth:
{ login: 'Login',
@@ -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',
@@ -291,12 +367,16 @@ export default {
outright_player_only: 'Player login required',
outright_shown_count: '{shown} / {total} teams shown',
outright_load_more: 'Load more',
outright_settled: 'Settled',
outright_settled_hint: 'This event is settled. Odds and results are view-only.',
cancel: 'Cancel',
parlay_max_legs: 'Parlay allows up to 5 legs',
parlay_block_outright: 'Outright cannot be parlayed',
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 +460,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',
@@ -27,6 +32,67 @@ export default {
banner_slide: 'Slaid {n}',
banner_fallback: 'Banner',
},
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',
stake: 'Jumlah',
@@ -64,6 +130,9 @@ export default {
stats_stake: 'Jumlah Taruhan',
stats_return: 'Jumlah Pulangan',
cashbacked: 'Rebat dibayar',
profit: 'Keuntungan',
return_incl_stake: 'Pulangan {amount} (termasuk jumlah)',
stake_to_return: '{stake} × {odds} = {amount}',
},
auth: {
login: 'Log Masuk',
@@ -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',
@@ -297,12 +373,16 @@ export default {
outright_player_only: 'Log masuk pemain diperlukan',
outright_shown_count: '{shown} / {total} pasukan dipaparkan',
outright_load_more: 'Muat lagi',
outright_settled: 'Selesai',
outright_settled_hint: 'Acara ini telah diselesaikan. Hanya paparan odds dan keputusan.',
cancel: 'Batal',
parlay_max_legs: 'Maksimum 5 pilihan parlay',
parlay_block_outright: 'Outright tidak boleh parlay',
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 +466,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 · 足球赛事火热进行中 · 理性投注,量力而行',
@@ -21,6 +26,67 @@ export default {
banner_slide: '第 {n} 张',
banner_fallback: 'Banner',
},
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: '足球',
stake: '投注',
@@ -58,6 +124,9 @@ export default {
stats_stake: '总投注额',
stats_return: '总回报',
cashbacked: '已回水',
profit: '净赢',
return_incl_stake: '回报 {amount}(含本金)',
stake_to_return: '{stake} × {odds} = {amount}',
},
auth: {
login: '登录',
@@ -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: '今日',
@@ -291,12 +367,16 @@ export default {
outright_player_only: '请使用玩家账号登录后查看',
outright_shown_count: '已显示 {shown} / {total} 队',
outright_load_more: '加载更多',
outright_settled: '已结算',
outright_settled_hint: '本赛事已结算,仅可查看赔率与冠军结果',
cancel: '取消',
parlay_max_legs: '串关最多 5 项',
parlay_block_outright: '冠军盘不可串关',
parlay_block_quarter: '四分盘让球/大小不可串关',
parlay_block_not_allowed: '该玩法不可串关',
parlay_need_more: '请至少选择 2 项进行串关',
market_status_suspended: '暂停',
market_status_closed: '已关闭',
back: '返回',
refresh: '刷新',
download: '下载',
@@ -380,12 +460,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

@@ -10,20 +10,22 @@ import { useAppLocale } from '../composables/useAppLocale';
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
import BottomNavIcon from '../components/BottomNavIcon.vue';
import BackToTopButton from '../components/BackToTopButton.vue';
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
import { computed, defineAsyncComponent, onMounted, onUnmounted, 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,39 +34,70 @@ 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 primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
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 bottomNavHidden = ref(false);
let lastMainScrollTop = 0;
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadPlayerHome(true);
function onMainScroll() {
const el = mainRef.value;
if (!el || !showBottomNav.value) return;
const scrollTop = el.scrollTop;
const delta = scrollTop - lastMainScrollTop;
if (scrollTop <= 8) {
bottomNavHidden.value = false;
} else if (delta > 10) {
bottomNavHidden.value = true;
} else if (delta < -10) {
bottomNavHidden.value = false;
}
lastMainScrollTop = scrollTop;
}
watch(showBottomNav, (visible) => {
if (!visible) bottomNavHidden.value = false;
});
watch(
() => route.fullPath,
(_path, oldPath) => {
lastMainScrollTop = 0;
bottomNavHidden.value = false;
const el = mainRef.value;
if (!el) return;
if (oldPath) tabScrollTops.set(oldPath, el.scrollTop);
@@ -73,10 +106,22 @@ watch(
);
const balanceRefreshPaths = ['/profile', '/wallet', '/bets'];
let mainScrollEl: HTMLElement | null = null;
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadPlayerHome(true);
});
onMounted(() => {
if (auth.user?.locale) void initFromUser(auth.user.locale);
bindProfileVisibilityRefresh();
mainScrollEl = mainRef.value;
mainScrollEl?.addEventListener('scroll', onMainScroll, { passive: true });
});
onUnmounted(() => {
mainScrollEl?.removeEventListener('scroll', onMainScroll);
mainScrollEl = null;
});
watch(
@@ -87,6 +132,13 @@ watch(
// 个人资料仅登录用户需要
if (token) {
void loadProfile(true);
startPolling();
startPresencePing();
if (inboxEnabled.value) void refreshUnreadCount();
} else {
stopPolling();
stopPresencePing();
resetMessagesState();
}
},
{ immediate: true },
@@ -96,6 +148,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();
}
@@ -108,13 +163,12 @@ watch(
<header v-if="showHeader" class="header">
<img src="/logo.png" alt="TheBet365" class="logo" />
<div class="header-actions">
<button
type="button"
class="support-btn"
:aria-label="t('support.open')"
@click="customerServiceOpen = true"
<RouterLink
:to="hubRoute"
class="hub-btn"
:aria-label="inboxEnabled && unreadCount ? t('messages.unread_badge', { count: unreadCount }) : t(hubOpenLabelKey)"
>
<svg class="support-icon" viewBox="0 0 24 24" aria-hidden="true">
<svg class="hub-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
fill="none"
@@ -126,8 +180,8 @@ 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>
<LocaleSwitcher />
<template v-if="auth.user">
<CashBalanceChip />
@@ -140,7 +194,11 @@ 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 }]">
@@ -152,7 +210,12 @@ watch(
</RouterView>
</main>
<nav v-if="showBottomNav" class="bottom-nav" aria-label="Main">
<nav
v-if="showBottomNav"
class="bottom-nav"
:class="{ 'bottom-nav--hidden': bottomNavHidden }"
aria-label="Main"
>
<RouterLink to="/" class="nav-item" :class="{ active: route.path === '/' }">
<BottomNavIcon name="home" />
<span class="nav-label">{{ t('nav.home') }}</span>
@@ -179,29 +242,37 @@ watch(
</RouterLink>
</nav>
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
<BackToTopButton
:scroll-el="mainRef"
:above-nav="showBottomNav && !bottomNavHidden"
/>
<BetSlipDrawer v-model="slip.drawerOpen" />
<CustomerServiceModal v-model="customerServiceOpen" />
</div>
</template>
<style scoped>
.layout {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
height: 100%;
min-height: 100dvh;
overflow: hidden;
background: transparent;
box-sizing: border-box;
padding-top: var(--safe-top);
}
.header {
flex-shrink: 0;
display: flex; justify-content: space-between; align-items: center;
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
min-height: var(--player-header-h);
background: rgba(17, 17, 17, 0.94);
border-bottom: 1px solid var(--border);
z-index: 110;
z-index: 120;
}
.logo {
height: 36px; width: auto; display: block;
@@ -224,39 +295,45 @@ watch(
width: var(--header-chip-h);
}
.support-btn {
.hub-btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
justify-content: center;
width: var(--header-chip-h, 36px);
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);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 1;
min-width: 0;
flex-shrink: 0;
text-decoration: none;
}
.support-btn:active {
.hub-btn:active {
background: rgba(200, 168, 78, 0.16);
}
.support-icon {
.hub-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.support-label {
max-width: 48px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.hub-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #e74c3c;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px rgba(17, 17, 17, 0.94);
}
.login-btn {
@@ -276,7 +353,8 @@ watch(
}
.announce-strip {
flex-shrink: 0;
z-index: 105;
position: relative;
z-index: 100;
border-bottom: 1px solid var(--border);
}
@@ -290,15 +368,30 @@ watch(
}
.main.has-nav {
padding-bottom: 16px;
padding-bottom: calc(var(--player-bottom-nav-h) + var(--safe-bottom));
}
.bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
box-sizing: border-box;
min-height: calc(var(--player-bottom-nav-h) + var(--safe-bottom));
padding-bottom: var(--safe-bottom);
background: rgba(17, 17, 17, 0.96);
border-top: 1px solid var(--border);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.35);
z-index: 100;
padding-bottom: env(safe-area-inset-bottom, 0);
transition: transform 0.28s ease, opacity 0.28s ease;
will-change: transform;
}
.bottom-nav--hidden {
transform: translateY(100%);
opacity: 0;
pointer-events: none;
}
.nav-item {
flex: 1;
@@ -307,7 +400,7 @@ watch(
align-items: center;
justify-content: center;
gap: 3px;
padding: 6px 2px 8px;
padding: 6px 2px 4px;
font-size: 9px;
color: var(--text-muted);
font-weight: 600;

View File

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

View File

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

@@ -40,6 +40,10 @@
#5C4A12 100%
);
--gradient-card: linear-gradient(160deg, #1E1A12 0%, #141414 40%, #0A0A0A 100%);
--player-header-h: 52px;
--player-bottom-nav-h: 54px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
/** 金色描边按钮(避免大面积实心填充) */
@@ -188,9 +192,19 @@ html,
body,
#app {
height: 100%;
min-height: 100%;
touch-action: manipulation;
}
html {
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
height: -webkit-fill-available;
}
body {
min-height: 100%;
min-height: -webkit-fill-available;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* 保留 bg 位图;用 scroll 替代 fixed减轻移动端滚动重绘后续可换 WebP/AVIF */
background-color: var(--tertiary);
@@ -207,6 +221,11 @@ body {
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100%;
min-height: -webkit-fill-available;
}
a { color: inherit; text-decoration: none; }
button {
@@ -223,7 +242,7 @@ input {
padding: 14px 16px;
border-radius: var(--radius-sm);
width: 100%;
font-size: 15px;
font-size: 16px;
font-weight: 500;
transition: border-color 0.2s, box-shadow 0.2s;
}

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

@@ -1,3 +1,14 @@
import { txDisplayAmount } from '@thebet365/shared';
export { txDisplayAmount };
/** 流水金额样式0 用中性色,正数金色,负数红色 */
export function txAmountClass(amount: string): 'zero' | 'pos' | 'neg' {
const n = parseFloat(amount);
if (n === 0) return 'zero';
return n > 0 ? 'pos' : 'neg';
}
export const TX_KEY_MAP: Record<string, string> = {
MANUAL_DEPOSIT: 'wallet.tx_deposit',
ADMIN_DEPOSIT: 'wallet.tx_admin_deposit',
@@ -87,3 +98,4 @@ export function isCashbackType(type: string): boolean {
const t = type.toUpperCase();
return t === 'CASHBACK' || t === 'CASHBACK_DEPOSIT';
}

View File

@@ -0,0 +1,320 @@
<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: #141414;
color: var(--gold);
font-size: 22px;
line-height: 1;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.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-gold-soft);
border-radius: 8px;
padding: 8px 14px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
font-size: 13px;
font-weight: 600;
}
.detail-article {
margin: 0;
padding: 0;
}
.detail-hero {
margin: 16px -16px 0;
padding: 0;
background: #080808;
line-height: 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: #fff;
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #d0d0d0;
}
.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(--gradient-gold);
color: #1a1000;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.link-action--outline {
background: transparent;
border: 1px solid var(--border-gold-soft);
color: var(--gold);
}
</style>

View File

@@ -0,0 +1,178 @@
<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: #141414;
color: var(--gold);
font-size: 22px;
line-height: 1;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.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: #fff;
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

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

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,75 +499,266 @@ function goMatch(id: string) {
line-height: 1;
}
.time-tabs {
.search-bar {
display: flex;
gap: 10px;
padding: 0 12px 14px;
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);
}
.time-tab {
flex: 1;
padding: 10px 16px;
border-radius: 999px;
.search-icon {
color: var(--gold);
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
background: #141414;
border: 1px solid var(--primary);
line-height: 1;
flex-shrink: 0;
}
.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);
.search-bar input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: transparent;
color: #fff;
font-size: 12px;
font-weight: 500;
line-height: 1.2;
outline: none;
-webkit-appearance: none;
appearance: none;
}
.search-bar input::placeholder {
color: var(--text-muted);
}
.filters-bar {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding: 0 12px 12px;
}
.phase-filter {
padding: 0 12px 10px;
padding: 0;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
}
.phase-toggle {
.right-filters {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.time-tabs {
display: flex;
align-items: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
height: 24px;
box-sizing: border-box;
flex-shrink: 0;
}
.time-tab {
padding: 0 8px;
height: 100%;
font-size: 11px;
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;
white-space: nowrap;
flex-shrink: 0;
}
.time-tab.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
font-weight: 600;
}
.right-filters .divider {
width: 1px;
height: 12px;
background: var(--border);
}
.status-toggle-btn {
padding: 0 8px;
height: 24px;
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;
white-space: nowrap;
flex-shrink: 0;
}
.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;
width: 100%;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
color: #999;
background: #141414;
border: 1px solid #333;
padding: 0 10px;
font-size: 11px;
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;
width: 100%;
max-width: 100%;
}
.phase-toggle--active {
color: var(--primary-light);
border-color: var(--primary);
background: rgba(200, 168, 78, 0.08);
.dropdown-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.phase-toggle-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #555;
transition: background 0.2s ease;
.dropdown-trigger .arrow-icon {
font-size: 8px;
color: var(--text-muted);
transition: transform 0.2s ease;
display: inline-block;
transform: scale(0.8);
}
.phase-toggle--active .phase-toggle-dot {
background: var(--primary-light);
.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: #0f1217;
border: 1px solid #2a3240;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.65);
padding: 6px 0;
}
.dropdown-item {
padding: 8px 14px;
font-size: 13px;
color: #e5e7eb;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #1f2937;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover,
.dropdown-item:active {
background: #1b2330;
}
.dropdown-item.active {
color: #f4d06f;
background: #242f42;
font-weight: 700;
}
.league-list {
padding: 4px 12px 0;
}
.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;
}
.state,
.placeholder,
.empty {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onActivated } from 'vue';
import { onActivated, computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
@@ -11,11 +11,28 @@ 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 { t, locale } = useI18n();
const router = useRouter();
const { banners, hotMatches, loading, load } = usePlayerHome();
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
const activeTab = ref<HotTab>('hot');
const bannerFallbackTo = computed(() => {
const id = announcementItems.value[0]?.id;
return id ? `/announcements/${id}` : '/announcements';
});
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
);
const emptyMessage = computed(() =>
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
);
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await load(true); },
@@ -47,14 +64,35 @@ function formatKickoff(startTime: string) {
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
</div>
<BannerCarousel :banners="banners" />
<BannerCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
<div class="hot-tabs" role="tablist" :aria-label="t('home.hot_matches')">
<button
type="button"
role="tab"
class="hot-tab"
:class="{ active: activeTab === 'hot' }"
:aria-selected="activeTab === 'hot'"
@click="activeTab = 'hot'"
>
{{ t('home.hot_tab') }}
</button>
<button
type="button"
role="tab"
class="hot-tab"
:class="{ active: activeTab === 'upcoming' }"
:aria-selected="activeTab === 'upcoming'"
@click="activeTab = 'upcoming'"
>
{{ t('home.upcoming_tab') }}
</button>
</div>
<div
v-for="(match, index) in hotMatches"
v-for="(match, index) in displayedMatches"
:key="match.id"
class="match-card"
:class="{ 'match-card--live-anim': index < 3 }"
:class="{ 'match-card--live-anim': activeTab === 'hot' && index < 3 }"
@click="goMatch(match.id)"
>
<div class="match-info">
@@ -102,9 +140,9 @@ function formatKickoff(startTime: string) {
</div>
</div>
<div v-if="!loading && !hotMatches.length" class="empty">
<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,6 +156,36 @@ function formatKickoff(startTime: string) {
transition: height 0.15s ease;
}
.hot-tabs {
display: flex;
gap: 8px;
margin: 14px 0;
}
.hot-tab {
flex: 1;
min-height: 36px;
padding: 0 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.hot-tab.active {
font-weight: 700;
background: rgba(244, 162, 97, 0.1);
border-color: var(--primary);
color: var(--primary-light);
}
.hot-tab:active {
opacity: 0.92;
}
.match-card {
position: relative;
isolation: isolate;

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: #141414;
color: var(--gold);
font-size: 22px;
line-height: 1;
cursor: pointer;
}
.page-header h1 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.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(--gold);
border-bottom-color: var(--gold);
}
.hub-tab-label {
line-height: 1.2;
}
.hub-tab-badge {
min-width: 18px;
padding: 0 5px;
border-radius: 9px;
background: var(--gold);
color: #111;
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-gold-soft);
border-radius: 8px;
background: rgba(212, 175, 55, 0.08);
color: var(--gold);
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.action-btn.danger {
border-color: rgba(255, 80, 80, 0.35);
background: rgba(255, 80, 80, 0.08);
color: #f88;
}
</style>

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"
@@ -554,6 +585,20 @@ function onPickSelection(selId: string, marketId: string) {
border-left: 1px solid rgba(200, 168, 78, 0.3);
}
.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 {
position: relative;
overflow: hidden;

View File

@@ -0,0 +1,392 @@
<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 GoldSpinner from '../components/GoldSpinner.vue';
import ConfirmDialog from '../components/ConfirmDialog.vue';
import { sanitizeAnnouncementHtml } from '../utils/html';
import { formatMoney } from '../utils/localeDisplay';
import {
usePlayerMessages,
type DepositMessagePayload,
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>
<div
v-if="message.type === 'ADMIN_CUSTOM'"
class="detail-body rich-body"
v-html="sanitizeAnnouncementHtml(message.body)"
/>
<p v-else 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: #141414;
color: var(--gold);
font-size: 22px;
line-height: 1;
}
.page-header h1 {
margin: 0;
flex: 1;
font-size: 18px;
font-weight: 700;
color: #fff;
}
.delete-header-btn {
border: 1px solid rgba(255, 80, 80, 0.35);
border-radius: 8px;
padding: 6px 10px;
background: rgba(255, 80, 80, 0.08);
color: #f88;
font-size: 12px;
font-weight: 600;
}
.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: #888;
background: rgba(255, 255, 255, 0.06);
}
.status-badge.unread {
color: var(--gold);
background: rgba(212, 175, 55, 0.12);
}
.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-gold-soft);
border-radius: 8px;
padding: 8px 14px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
font-size: 13px;
font-weight: 600;
}
.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: #fff;
}
.detail-body {
margin: 0;
font-size: 15px;
line-height: 1.75;
color: #d0d0d0;
}
.rich-body :deep(p) {
margin: 0 0 12px;
}
.rich-body :deep(p:last-child) {
margin-bottom: 0;
}
.rich-body :deep(img) {
display: block;
max-width: 100%;
height: auto;
margin: 12px 0;
border-radius: 8px;
}
.rich-body :deep(ul),
.rich-body :deep(ol) {
margin: 0 0 12px;
padding-left: 20px;
}
.rich-body :deep(a) {
color: var(--gold);
}
.reason-box {
margin-top: 18px;
padding: 14px;
border-radius: 10px;
border: 1px solid rgba(255, 107, 107, 0.25);
background: rgba(255, 107, 107, 0.08);
}
.reason-label {
margin: 0 0 8px;
font-size: 12px;
color: #ffb4b4;
font-weight: 700;
}
.reason-text {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: #ffe0e0;
}
.action-btn {
margin-top: 24px;
width: 100%;
padding: 11px 14px;
border: 1px solid var(--border-gold-soft);
border-radius: 8px;
background: rgba(212, 175, 55, 0.1);
color: var(--gold);
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
</style>

View File

@@ -8,6 +8,7 @@ import LocaleFlag from '../components/LocaleFlag.vue';
import { useAuthStore } from '../stores/auth';
import { useAppLocale } from '../composables/useAppLocale';
import { usePlayerProfile } from '../composables/usePlayerProfile';
import { useInboxFeature } from '../composables/useInboxFeature';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
@@ -18,6 +19,7 @@ const router = useRouter();
const auth = useAuthStore();
const { locales, setLocale, initFromUser } = useAppLocale();
const { profileRaw, refreshProfile } = usePlayerProfile();
const { hubRoute } = useInboxFeature();
const loading = ref(true);
const error = ref(false);
@@ -186,6 +188,17 @@ const balanceAmountClass = computed(() => {
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink :to="hubRoute" class="settings-cell settings-cell--gold-entry">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11A2.5 2.5 0 0 1 17.5 20H6.5A2.5 2.5 0 0 1 4 17.5v-11Z" />
<path d="m4 7 8 5.5L20 7" />
</svg>
<span class="cell-label">{{ t('messages.title') }}</span>
</span>
<span class="cell-chevron" aria-hidden="true"></span>
</RouterLink>
<RouterLink to="/wallet/recharge/history" class="settings-cell settings-cell--gold-entry">
<span class="cell-main">
<svg class="cell-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">

View File

@@ -12,9 +12,11 @@ import {
formatDepositAuditRemark,
shouldShowAuditRejectInTimeline,
} from '../utils/depositAuditDisplay';
import { useDepositNotifications } from '../composables/useDepositNotifications';
const router = useRouter();
const { t, locale } = useI18n();
const { trackPendingOrder, pollOnce } = useDepositNotifications();
interface DepositAuditLog {
id: string;
@@ -66,6 +68,10 @@ async function fetchOrders(p = 1) {
if (p === 1) {
items.value = newItems;
for (const order of newItems) {
if (order.status === 'PENDING') trackPendingOrder(order.id);
}
void pollOnce();
} else {
items.value = [...items.value, ...newItems];
}
@@ -316,10 +322,10 @@ function auditStepCount(order: DepositOrder) {
<Teleport to="body">
<div v-if="selectedOrder" class="detail-overlay" @click.self="closeDetail">
<div class="detail-modal" role="dialog" aria-modal="true" :aria-label="t('recharge.order_detail')">
<div class="detail-modal" role="dialog" aria-modal="true" :aria-label="t('recharge.audit_title')">
<button type="button" class="detail-close" :aria-label="t('common.close')" @click="closeDetail"></button>
<h3 class="detail-title">{{ t('recharge.order_detail') }}</h3>
<h3 class="detail-title">{{ t('recharge.audit_title') }}</h3>
<div class="detail-summary">
<div class="detail-summary-head">
@@ -409,7 +415,7 @@ function auditStepCount(order: DepositOrder) {
</template>
<style scoped>
.recharge-history-page { padding: 0 16px 24px; }
.recharge-history-page { padding: 0 0 24px; }
.page-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; }
.page-header h2 { margin: 0; font-size: 17px; font-weight: 700; }
.back-btn { background: none; border: none; color: var(--primary-light); font-size: 24px; cursor: pointer; padding: 0 8px; }

View File

@@ -5,10 +5,12 @@ import { useI18n } from 'vue-i18n';
import imageCompression from 'browser-image-compression';
import api from '../api';
import GoldSpinner from '../components/GoldSpinner.vue';
import { useDepositNotifications } from '../composables/useDepositNotifications';
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { trackPendingOrder } = useDepositNotifications();
const reapplyOrderId = computed(() => {
const id = route.query.orderId;
@@ -93,30 +95,44 @@ function selectMethod(m: PaymentMethod) {
}
const MAX_ORIGINAL_BYTES = 10 * 1024 * 1024;
const MAX_SCREENSHOT_BYTES = 1024 * 1024;
const MAX_SCREENSHOT_BYTES = 300 * 1024;
async function compressScreenshot(file: File): Promise<File> {
const baseOptions = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
maxSizeMB: 0.3,
maxWidthOrHeight: 1200,
useWebWorker: true,
maxIteration: 15,
maxIteration: 10,
fileType: 'image/webp',
} as const;
const attempts = [
{ ...baseOptions, initialQuality: 0.85 },
{ ...baseOptions, initialQuality: 0.65, maxWidthOrHeight: 1600 },
{ ...baseOptions, initialQuality: 0.5, maxWidthOrHeight: 1280 },
{ ...baseOptions, initialQuality: 0.8 },
{ ...baseOptions, initialQuality: 0.6, maxWidthOrHeight: 1000 },
];
let compressed: File | null = null;
for (const options of attempts) {
const compressed = (await imageCompression(file, options)) as File;
if (compressed.size <= MAX_SCREENSHOT_BYTES) {
return compressed;
try {
compressed = (await imageCompression(file, options)) as File;
if (compressed.size <= MAX_SCREENSHOT_BYTES) {
break;
}
} catch (err) {
console.error('Compression attempt failed:', err);
}
}
throw new Error('COMPRESS_TOO_LARGE');
if (!compressed) {
throw new Error('COMPRESS_FAILED');
}
// 重命名文件后缀为 .webp
const name = file.name.replace(/\.[^/.]+$/, "") + '.webp';
return new File([compressed], name, {
type: 'image/webp',
lastModified: Date.now(),
});
}
async function handleFileChange(event: Event) {
@@ -186,6 +202,7 @@ async function handleSubmit() {
const { data } = await api.post(`/player/deposit-orders/${reapplyOrderId.value}/reapply`, fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
if (result?.id) trackPendingOrder(String(result.id));
success.value = true;
return;
}
@@ -194,6 +211,7 @@ async function handleSubmit() {
const { data } = await api.post('/player/deposit-orders', fd);
const result = data.data;
orderNo.value = result?.orderNo ?? '';
if (result?.id) trackPendingOrder(String(result.id));
success.value = true;
} catch (e: any) {
alert(e.response?.data?.message || t('recharge.submit_failed'));
@@ -362,7 +380,7 @@ onMounted(fetchMethods);
</template>
<style scoped>
.recharge-page { padding: 0 12px 24px; }
.recharge-page { padding: 0 0 24px; }
.page-header {
display: flex; align-items: center; justify-content: space-between;

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { isBetType, isDepositType, isWithdrawType, isCashbackType, txTypeKey, txDisplayType, txSummaryLabel } from '../utils/walletTx';
import { isBetType, isDepositType, isWithdrawType, isCashbackType, txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../utils/walletTx';
import GoldSpinner from '../components/GoldSpinner.vue';
import WalletStatsPanel from '../components/WalletStatsPanel.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -19,6 +19,8 @@ type Transaction = {
summaryKind?: 'opening_bonus' | null;
referenceType?: string | null;
amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
transactionId: string;
};
@@ -198,7 +200,7 @@ const pullIndicatorStyle = () => ({
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
</div>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
<span :class="txAmountClass(tx.amount)">
{{ formatMoney(tx.amount, locale) }}
</span>
</div>
@@ -358,6 +360,7 @@ const pullIndicatorStyle = () => ({
.tx-type { font-weight: 700; color: var(--text); }
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
.neg { color: var(--danger); font-weight: 700; }
.zero { color: var(--text-muted); font-weight: 700; font-size: 15px; }
.tx-time { font-size: 11px; color: var(--text-muted); }
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }

View File

@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import { txTypeKey, isCashbackType, txDisplayType, txSummaryLabel, txRemarkLabel, isDepositReversalType } from '../utils/walletTx';
import { txTypeKey, isCashbackType, txDisplayType, txAmountClass, txSummaryLabel, txRemarkLabel, isDepositReversalType } from '../utils/walletTx';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -81,10 +81,7 @@ const summaryText = computed(() => {
return txSummaryLabel(tx.value, t);
});
const amountClass = computed(() => {
if (!tx.value) return '';
return parseFloat(tx.value.amount) >= 0 ? 'pos' : 'neg';
});
const amountClass = computed(() => (tx.value ? txAmountClass(tx.value.amount) : 'zero'));
const formattedTime = computed(() => {
if (!tx.value) return '';
@@ -279,6 +276,11 @@ function goCashbackDetail() {
background: linear-gradient(135deg, rgba(224, 80, 80, 0.1), rgba(224, 80, 80, 0.03));
}
.hero.zero {
border-color: rgba(255, 255, 255, 0.08);
background: #141414;
}
.hero-type {
font-size: 12px;
font-weight: 800;
@@ -303,6 +305,7 @@ function goCashbackDetail() {
.hero.pos .hero-amount { color: var(--primary-light); }
.hero.neg .hero-amount { color: var(--danger); }
.hero.zero .hero-amount { color: var(--text-muted, #888); }
.hero-time {
font-size: 11px;
@@ -350,6 +353,7 @@ function goCashbackDetail() {
.pos { color: var(--primary-light) !important; }
.neg { color: var(--danger) !important; }
.zero { color: var(--text-muted, #888) !important; }
.mono {
font-family: ui-monospace, monospace;

View File

@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { formatMoney, formatMoneyCompact } from '../utils/localeDisplay';
import { txTypeKey, txDisplayType, txSummaryLabel } from '../utils/walletTx';
import { txTypeKey, txDisplayType, txAmountClass, txSummaryLabel } from '../utils/walletTx';
import { parseCashbackApiData, sumCashbackAmount } from '../utils/cashback';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
@@ -19,6 +19,8 @@ type Transaction = {
summaryKind?: 'opening_bonus' | null;
referenceType?: string | null;
amount: string;
frozenBefore?: string;
frozenAfter?: string;
createdAt: string;
transactionId: string;
};
@@ -121,7 +123,7 @@ const pullIndicatorStyle = () => ({
<span class="tx-type">{{ txLabel(tx) }}</span>
<span v-if="txSubtitle(tx)" class="tx-summary">{{ txSubtitle(tx) }}</span>
</div>
<span :class="parseFloat(tx.amount) >= 0 ? 'pos' : 'neg'">
<span :class="txAmountClass(tx.amount)">
{{ formatMoney(tx.amount, locale) }}
</span>
</div>
@@ -225,6 +227,7 @@ const pullIndicatorStyle = () => ({
.tx-type { font-weight: 700; color: var(--text); }
.pos { color: var(--primary-light); font-weight: 800; font-size: 15px; }
.neg { color: var(--danger); font-weight: 700; }
.zero { color: var(--text-muted); font-weight: 700; font-size: 15px; }
.tx-time { font-size: 11px; color: var(--text-muted); }
.tx-arrow { font-size: 16px; color: #555; font-weight: 700; line-height: 1; }

View File

@@ -1,6 +1,9 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
import { resolveDevApiTarget } from '../../scripts/resolve-dev-api-target.mjs';
const devApiTarget = resolveDevApiTarget();
export default defineConfig({
plugins: [vue()],
@@ -35,10 +38,11 @@ export default defineConfig({
},
publicDir: resolve(__dirname, '../../packages/shared/public'),
server: {
host: true,
port: 5173,
proxy: {
'/api': { target: 'http://localhost:3000', changeOrigin: true },
'/uploads': { target: 'http://localhost:3000', changeOrigin: true },
'/api': { target: devApiTarget, changeOrigin: true },
'/uploads': { target: devApiTarget, changeOrigin: true },
},
},
});

View File

@@ -75,7 +75,7 @@ services:
- thebet365
player:
image: thebet365-player:${IMAGE_TAG:-latest}
image: thebet365-player:${PLAYER_IMAGE_TAG:-main}
build:
context: .
dockerfile: docker/player/Dockerfile

59
docker-compose.themes.yml Normal file
View File

@@ -0,0 +1,59 @@
# 四套主题扩展 — 与 docker-compose.prod.yml 合并使用:
# docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d
#
# 关联文档docs/四套主题Docker部署任务.md
services:
player2:
image: thebet365-player:${PLAYER2_IMAGE_TAG:-theme-2}
container_name: thebet365-player2
depends_on:
api:
condition: service_healthy
ports:
- '${BIND_ADDR:-127.0.0.1}:${PLAYER2_PORT:-8083}:80'
healthcheck:
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1']
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
restart: unless-stopped
networks:
- thebet365
player3:
image: thebet365-player:${PLAYER3_IMAGE_TAG:-theme-3}
container_name: thebet365-player3
depends_on:
api:
condition: service_healthy
ports:
- '${BIND_ADDR:-127.0.0.1}:${PLAYER3_PORT:-8084}:80'
healthcheck:
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1']
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
restart: unless-stopped
networks:
- thebet365
player4:
image: thebet365-player:${PLAYER4_IMAGE_TAG:-theme-4}
container_name: thebet365-player4
depends_on:
api:
condition: service_healthy
ports:
- '${BIND_ADDR:-127.0.0.1}:${PLAYER4_PORT:-8085}:80'
healthcheck:
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1']
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
restart: unless-stopped
networks:
- thebet365

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

@@ -0,0 +1,13 @@
@echo off
REM Thin launcher for build-and-export-all-themes.ps1
REM CMD reads batch files line-by-line from disk; git checkout to theme-* removes
REM this file from the working tree. PowerShell loads the script into memory first.
REM
REM Usage:
REM docs\docker\build-and-export-all-themes.bat
REM docs\docker\build-and-export-all-themes.bat --use-cache
REM docs\docker\build-and-export-all-themes.bat --export-only
REM docs\docker\build-and-export-all-themes.bat --skip-api-admin
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build-and-export-all-themes.ps1" %*
exit /b %ERRORLEVEL%

View File

@@ -0,0 +1,285 @@
# 四套主题 player 一键打包(自动切换 Git 分支)
# PowerShell 会在启动时将整份脚本载入内存git checkout 后仍可继续执行。
# 用法(项目根目录或本目录均可):
# .\docs\docker\build-and-export-all-themes.ps1
# .\docs\docker\build-and-export-all-themes.ps1 -UseCache
# .\docs\docker\build-and-export-all-themes.ps1 -ExportOnly
# .\docs\docker\build-and-export-all-themes.ps1 -SkipApiAdmin
# 或通过 bat 启动(参数支持 --use-cache 等 CMD 风格):
# docs\docker\build-and-export-all-themes.bat --use-cache
param(
[switch]$UseCache,
[switch]$ExportOnly,
[switch]$SkipApiAdmin,
[switch]$SkipBundle,
[switch]$Help
)
$ErrorActionPreference = "Stop"
foreach ($arg in $args) {
switch ($arg.ToLowerInvariant()) {
"--use-cache" { $UseCache = $true; continue }
"--export-only" { $ExportOnly = $true; continue }
"--skip-api-admin" { $SkipApiAdmin = $true; continue }
"--skip-bundle" { $SkipBundle = $true; continue }
"-h" { $Help = $true; continue }
"--help" { $Help = $true; continue }
"/?" { $Help = $true; continue }
default { throw "Unknown argument: $arg" }
}
}
function Show-Help {
Write-Host @"
Usage: docs\docker\build-and-export-all-themes.ps1 [options]
-UseCache, --use-cache Use Docker build cache (default: --no-cache)
-ExportOnly, --export-only Skip build, export existing images only
-SkipApiAdmin, --skip-api-admin
Only build 4 player themes, skip api/admin
-SkipBundle, --skip-bundle Skip thebet365-full-themes-latest.tar (6-in-1 bundle)
-Help, -h, --help Show this help
Output (project root):
thebet365-player-main.tar
thebet365-player-theme-2.tar
thebet365-player-theme-3.tar
thebet365-player-theme-4.tar
thebet365-images-latest.tar (api + admin, unless -SkipApiAdmin)
thebet365-full-themes-latest.tar (api + admin + 4 player themes, unless -SkipBundle)
"@
}
if ($Help) {
Show-Help
exit 0
}
$Root = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path
$Themes = @(
@{ Branch = "main"; Tag = "main" },
@{ Branch = "theme-2"; Tag = "theme-2" },
@{ Branch = "theme-3"; Tag = "theme-3" },
@{ Branch = "theme-4"; Tag = "theme-4" }
)
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
throw "docker not found. Please start Docker Desktop first."
}
if (-not (Test-Path (Join-Path $Root "docs\docker\build-and-export-images.bat"))) {
throw "Missing build script: docs\docker\build-and-export-images.bat"
}
function Invoke-Git {
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GitArgs)
$prevErrorAction = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
try {
& git @GitArgs 2>$null | Out-Null
return $LASTEXITCODE
} finally {
$ErrorActionPreference = $prevErrorAction
}
}
function Get-GitOutput {
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$GitArgs)
$prevErrorAction = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
try {
return ((& git @GitArgs 2>$null | Out-String).Trim())
} finally {
$ErrorActionPreference = $prevErrorAction
}
}
Set-Location $Root
$OriginalBranch = Get-GitOutput rev-parse --abbrev-ref HEAD
if ([string]::IsNullOrWhiteSpace($OriginalBranch)) {
throw "Cannot determine current Git branch. Run this script from the repo root."
}
Write-Host "[INFO] Current branch: $OriginalBranch"
$DidStash = $false
$diffExit = Invoke-Git diff --quiet
$cachedExit = Invoke-Git diff --cached --quiet
if ($diffExit -ne 0 -or $cachedExit -ne 0) {
Write-Host "[INFO] Stashing local changes before branch switching..."
$stashExit = Invoke-Git stash push -m "build-all-themes auto-stash"
if ($stashExit -eq 0) {
$DidStash = $true
Write-Host "[INFO] Changes stashed."
} else {
Write-Warning "git stash failed. Branch switching may fail if there are conflicts."
}
}
$FailedThemes = [System.Collections.Generic.List[string]]::new()
$SuccessCount = 0
function Invoke-BuildExport {
param(
[string]$Service = "",
[string]$Tag
)
$argList = @()
if (-not [string]::IsNullOrWhiteSpace($Service)) {
$argList += "--service"
$argList += $Service
}
$argList += "--tag"
$argList += $Tag
if ($UseCache) { $argList += "--use-cache" }
if ($ExportOnly) { $argList += "--export-only" }
Set-Location $Root
$batPath = Join-Path $Root "docs\docker\build-and-export-images.bat"
$argText = ($argList | ForEach-Object {
if ($_ -match '\s') { "`"$_`"" } else { $_ }
}) -join ' '
& cmd.exe /c "`"$batPath`" $argText"
return $LASTEXITCODE
}
function Invoke-PlayerThemeBuild {
param(
[string]$Branch,
[string]$Tag
)
Write-Host ""
Write-Host "----------------------------------------------------------------"
Write-Host "[INFO] Theme: $Branch (image tag: $Tag)"
Write-Host "----------------------------------------------------------------"
Set-Location $Root
$checkoutExit = Invoke-Git checkout $Branch
if ($checkoutExit -ne 0) {
Write-Host "[ERROR] Cannot switch to branch $Branch -- skipping"
$script:FailedThemes.Add($Branch) | Out-Null
return
}
Write-Host "[INFO] Switched to $Branch"
Set-Location $Root
if ((Invoke-BuildExport -Service "player" -Tag $Tag) -ne 0) {
Write-Host "[ERROR] Player build failed for $Branch"
$script:FailedThemes.Add($Branch) | Out-Null
return
}
Write-Host "[OK] thebet365-player:$Tag exported successfully"
$script:SuccessCount++
}
foreach ($theme in $Themes) {
Invoke-PlayerThemeBuild -Branch $theme.Branch -Tag $theme.Tag
}
Write-Host ""
Write-Host "[INFO] Restoring original branch: $OriginalBranch"
Set-Location $Root
if ((Invoke-Git checkout $OriginalBranch) -eq 0) {
Write-Host "[INFO] Restored to $OriginalBranch"
} else {
Write-Warning "Could not restore branch $OriginalBranch. Run: git checkout $OriginalBranch"
}
if ($DidStash) {
Write-Host "[INFO] Restoring stashed changes..."
if ((Invoke-Git stash pop) -eq 0) {
Write-Host "[INFO] Stash restored."
} else {
Write-Warning "git stash pop failed. Run: git stash pop"
}
}
if (-not $SkipApiAdmin) {
Write-Host ""
Write-Host "[INFO] Building api + admin (tag: latest) on branch main..."
Set-Location $Root
if ((Invoke-Git checkout main) -ne 0) {
Write-Host "[ERROR] Cannot switch to branch main for api/admin build"
$FailedThemes.Add("api/admin") | Out-Null
} else {
Set-Location $Root
if ((Invoke-BuildExport -Service "api-admin" -Tag "latest") -ne 0) {
Write-Host "[ERROR] api/admin build failed"
$FailedThemes.Add("api/admin") | Out-Null
} else {
$SuccessCount++
}
}
Write-Host ""
Write-Host "[INFO] Restoring original branch: $OriginalBranch"
Set-Location $Root
Invoke-Git checkout $OriginalBranch | Out-Null
}
if (-not $SkipBundle -and -not $SkipApiAdmin -and $FailedThemes.Count -eq 0) {
Write-Host ""
Write-Host "[INFO] Exporting full bundle (api + admin + 4 player themes)..."
Set-Location $Root
if ((Invoke-BuildExport -Service "full-themes" -Tag "latest") -ne 0) {
Write-Host "[ERROR] Full bundle export failed"
$FailedThemes.Add("full-bundle") | Out-Null
} else {
Write-Host "[OK] thebet365-full-themes-latest.tar exported successfully"
$SuccessCount++
}
}
Write-Host ""
Write-Host "================================================================"
Write-Host " BUILD SUMMARY"
Write-Host "================================================================"
Write-Host " Succeeded: $SuccessCount task(s)"
if ($FailedThemes.Count -gt 0) {
Write-Host " Failed: $($FailedThemes -join ' ')"
Write-Host ""
Write-Host " Tip: Retry failed themes manually:"
Write-Host " git checkout <branch>"
Write-Host " docs\docker\build-and-export-images.bat --service player --tag <tag>"
Write-Host "================================================================"
exit 1
}
Write-Host ""
Write-Host " All done! Output files in project root:"
Write-Host " thebet365-player-main.tar"
Write-Host " thebet365-player-theme-2.tar"
Write-Host " thebet365-player-theme-3.tar"
Write-Host " thebet365-player-theme-4.tar"
if (-not $SkipApiAdmin) {
Write-Host " thebet365-images-latest.tar (api + admin)"
}
if (-not $SkipBundle -and -not $SkipApiAdmin) {
Write-Host " thebet365-full-themes-latest.tar (api + admin + 4 themes, upload this only)"
}
Write-Host ""
Write-Host " Server import (single bundle):"
Write-Host " docker load -i thebet365-full-themes-latest.tar"
Write-Host " docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d"
Write-Host ""
Write-Host " Or import separate tars:"
if (-not $SkipApiAdmin) {
Write-Host " docker load -i thebet365-images-latest.tar"
}
Write-Host " docker load -i thebet365-player-main.tar"
Write-Host " docker load -i thebet365-player-theme-2.tar"
Write-Host " docker load -i thebet365-player-theme-3.tar"
Write-Host " docker load -i thebet365-player-theme-4.tar"
Write-Host "================================================================"
exit 0

View File

@@ -8,6 +8,9 @@ REM docs\docker\build-and-export-images.bat --service admin
REM docs\docker\build-and-export-images.bat --tag v1.2.3
REM docs\docker\build-and-export-images.bat --use-cache
REM docs\docker\build-and-export-images.bat --export-only
REM
REM 四套主题玩家端一键打包(自动切分支,推荐):
REM docs\docker\build-and-export-all-themes.bat
cd /d "%~dp0..\.."
@@ -66,7 +69,7 @@ goto parse_args
echo.
echo Usage: docs\docker\build-and-export-images.bat [options]
echo.
echo --service SVC api ^| player ^| admin ^| all (default: all)
echo --service SVC api ^| player ^| admin ^| api-admin ^| full-themes ^| all (default: all)
echo --tag TAG Image tag (default: latest)
echo --use-cache Use Docker build cache
echo --export-only Skip build, export existing images only
@@ -78,6 +81,9 @@ echo docs\docker\build-and-export-images.bat --service admin
echo docs\docker\build-and-export-admin.bat
echo docs\docker\build-and-export-admin.bat --export-only
echo.
echo 四套主题玩家端批量打包(自动切分支,推荐):
echo docs\docker\build-and-export-all-themes.bat
echo.
exit /b 0
:args_done
@@ -111,8 +117,7 @@ if errorlevel 1 exit /b 1
if not defined OUTPUT call :default_output
set "OUTPUT_PATH=%OUTPUT%"
echo %OUTPUT_PATH%| findstr /r "^[A-Za-z]:\\" >nul
if errorlevel 1 set "OUTPUT_PATH=%CD%\%OUTPUT%"
if /i not "%OUTPUT:~1,1%"==":" set "OUTPUT_PATH=%CD%\%OUTPUT%"
where docker >nul 2>&1
if errorlevel 1 (
@@ -120,7 +125,7 @@ if errorlevel 1 (
exit /b 1
)
if "%EXPORT_ONLY%"=="0" goto do_build
if "%EXPORT_ONLY%"=="0" if /i not "%SERVICE%"=="full-themes" goto do_build
goto do_export
:do_build
@@ -129,6 +134,8 @@ set "IMAGE_TAG=%TAG%"
if "%NO_CACHE%"=="1" goto build_no_cache
if /i "%SERVICE%"=="all" (
docker compose -f "%COMPOSE_FILE%" --env-file "%ENV_FILE%" build api player admin
) else if /i "%SERVICE%"=="api-admin" (
docker compose -f "%COMPOSE_FILE%" --env-file "%ENV_FILE%" build api admin
) else (
docker compose -f "%COMPOSE_FILE%" --env-file "%ENV_FILE%" build %SERVICE%
)
@@ -137,6 +144,8 @@ goto build_done
:build_no_cache
if /i "%SERVICE%"=="all" (
docker compose -f "%COMPOSE_FILE%" --env-file "%ENV_FILE%" build --no-cache api player admin
) else if /i "%SERVICE%"=="api-admin" (
docker compose -f "%COMPOSE_FILE%" --env-file "%ENV_FILE%" build --no-cache api admin
) else (
docker compose -f "%COMPOSE_FILE%" --env-file "%ENV_FILE%" build --no-cache %SERVICE%
)
@@ -151,6 +160,10 @@ if errorlevel 1 (
echo ==^> Exporting %SERVICE% to %OUTPUT_PATH%
if /i "%SERVICE%"=="all" (
docker save thebet365-api:%TAG% thebet365-player:%TAG% thebet365-admin:%TAG% -o "%OUTPUT_PATH%"
) else if /i "%SERVICE%"=="api-admin" (
docker save thebet365-api:%TAG% thebet365-admin:%TAG% -o "%OUTPUT_PATH%"
) else if /i "%SERVICE%"=="full-themes" (
docker save thebet365-api:%TAG% thebet365-admin:%TAG% thebet365-player:main thebet365-player:theme-2 thebet365-player:theme-3 thebet365-player:theme-4 -o "%OUTPUT_PATH%"
) else (
docker save thebet365-%SERVICE%:%TAG% -o "%OUTPUT_PATH%"
)
@@ -181,16 +194,34 @@ for %%F in ("%OUTPUT_PATH%") do set /a SIZE_MB=%%~zF/1048576
echo git_dirty=!GIT_DIRTY!
echo tar=!TAR_NAME!
echo tar_sha256=!TAR_SHA256!
if /i not "!SERVICE!"=="all" (
echo image=thebet365-!SERVICE!:%TAG%
echo image_id=!SVC_IMAGE_ID!
) else (
if /i "!SERVICE!"=="all" (
echo api_image=thebet365-api:%TAG%
echo api_image_id=!API_IMAGE_ID!
echo player_image=thebet365-player:%TAG%
echo player_image_id=!PLAYER_IMAGE_ID!
echo admin_image=thebet365-admin:%TAG%
echo admin_image_id=!ADMIN_IMAGE_ID!
) else if /i "!SERVICE!"=="api-admin" (
echo api_image=thebet365-api:%TAG%
echo api_image_id=!API_IMAGE_ID!
echo admin_image=thebet365-admin:%TAG%
echo admin_image_id=!ADMIN_IMAGE_ID!
) else if /i "!SERVICE!"=="full-themes" (
echo api_image=thebet365-api:%TAG%
echo api_image_id=!API_IMAGE_ID!
echo admin_image=thebet365-admin:%TAG%
echo admin_image_id=!ADMIN_IMAGE_ID!
echo player_main_image=thebet365-player:main
echo player_main_image_id=!PLAYER_MAIN_IMAGE_ID!
echo player_theme_2_image=thebet365-player:theme-2
echo player_theme_2_image_id=!PLAYER_THEME2_IMAGE_ID!
echo player_theme_3_image=thebet365-player:theme-3
echo player_theme_3_image_id=!PLAYER_THEME3_IMAGE_ID!
echo player_theme_4_image=thebet365-player:theme-4
echo player_theme_4_image_id=!PLAYER_THEME4_IMAGE_ID!
) else (
echo image=thebet365-!SERVICE!:%TAG%
echo image_id=!SVC_IMAGE_ID!
)
)
@@ -201,6 +232,12 @@ echo Server load and recreate:
if /i "%SERVICE%"=="all" (
echo docker load -i !TAR_NAME!
echo docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --force-recreate api player admin
) else if /i "%SERVICE%"=="api-admin" (
echo docker load -i !TAR_NAME!
echo docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --force-recreate api admin
) else if /i "%SERVICE%"=="full-themes" (
echo docker load -i !TAR_NAME!
echo docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d
) else (
echo docker load -i !TAR_NAME!
echo docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --force-recreate %SERVICE%
@@ -217,6 +254,10 @@ exit /b 0
:default_output
if /i "%SERVICE%"=="all" (
set "OUTPUT=thebet365-images-%TAG%.tar"
) else if /i "%SERVICE%"=="api-admin" (
set "OUTPUT=thebet365-images-%TAG%.tar"
) else if /i "%SERVICE%"=="full-themes" (
set "OUTPUT=thebet365-full-themes-%TAG%.tar"
) else (
set "OUTPUT=thebet365-%SERVICE%-%TAG%.tar"
)
@@ -224,10 +265,13 @@ exit /b 0
:validate_service
if /i "%SERVICE%"=="all" exit /b 0
if /i "%SERVICE%"=="api-admin" exit /b 0
if /i "%SERVICE%"=="full-themes" exit /b 0
if /i "%SERVICE%"=="api" exit /b 0
if /i "%SERVICE%"=="player" exit /b 0
if /i "%SERVICE%"=="admin" exit /b 0
echo ERROR: invalid --service: %SERVICE% (use api, player, admin, or all)
echo ERROR: invalid --service: %SERVICE% (use api, player, admin, api-admin, full-themes, or all)
echo TIP: 四套主题一键打包请使用 docs\docker\build-and-export-all-themes.bat
exit /b 1
:validate_tag
@@ -266,10 +310,19 @@ set "API_IMAGE_ID="
set "PLAYER_IMAGE_ID="
set "ADMIN_IMAGE_ID="
set "SVC_IMAGE_ID="
set "PLAYER_MAIN_IMAGE_ID="
set "PLAYER_THEME2_IMAGE_ID="
set "PLAYER_THEME3_IMAGE_ID="
set "PLAYER_THEME4_IMAGE_ID="
for /f "delims=" %%I in ('docker image inspect thebet365-api:%TAG% --format "{{.Id}}" 2^>nul') do set "API_IMAGE_ID=%%I"
for /f "delims=" %%I in ('docker image inspect thebet365-player:%TAG% --format "{{.Id}}" 2^>nul') do set "PLAYER_IMAGE_ID=%%I"
for /f "delims=" %%I in ('docker image inspect thebet365-admin:%TAG% --format "{{.Id}}" 2^>nul') do set "ADMIN_IMAGE_ID=%%I"
if /i not "%SERVICE%"=="all" (
if /i "%SERVICE%"=="full-themes" (
for /f "delims=" %%I in ('docker image inspect thebet365-player:main --format "{{.Id}}" 2^>nul') do set "PLAYER_MAIN_IMAGE_ID=%%I"
for /f "delims=" %%I in ('docker image inspect thebet365-player:theme-2 --format "{{.Id}}" 2^>nul') do set "PLAYER_THEME2_IMAGE_ID=%%I"
for /f "delims=" %%I in ('docker image inspect thebet365-player:theme-3 --format "{{.Id}}" 2^>nul') do set "PLAYER_THEME3_IMAGE_ID=%%I"
for /f "delims=" %%I in ('docker image inspect thebet365-player:theme-4 --format "{{.Id}}" 2^>nul') do set "PLAYER_THEME4_IMAGE_ID=%%I"
) else if /i not "%SERVICE%"=="all" (
for /f "delims=" %%I in ('docker image inspect thebet365-%SERVICE%:%TAG% --format "{{.Id}}" 2^>nul') do set "SVC_IMAGE_ID=%%I"
)
exit /b 0

View File

@@ -1,13 +1,17 @@
# 构建 api / player / admin 生产镜像并导出为 tar
# 用法(在项目根目录或本目录执行均可):
# ?? api / player / admin ???????? tar
# ??????????????????:
# .\docs\docker\build-and-export-images.ps1
# .\docs\docker\build-and-export-images.ps1 -Service admin
# .\docs\docker\build-and-export-images.ps1 -Tag v1.2.3
# .\docs\docker\build-and-export-images.ps1 -UseCache
# .\docs\docker\build-and-export-images.ps1 -ExportOnly
#
# ????????????????????????????????????
# docs\docker\build-and-export-all-themes.bat
param(
[ValidateSet("all", "api", "player", "admin")]
[ValidateSet("all", "api", "player", "admin", "api-admin")]
[string]$Service = "all",
[string]$Tag = $env:IMAGE_TAG,
[switch]$UseCache,
@@ -27,27 +31,27 @@ function Get-DefaultTag { "latest" }
function Get-DefaultOutput {
param([string]$Svc, [string]$ImageTag)
if ($Svc -eq "all") { return "thebet365-images-$ImageTag.tar" }
if ($Svc -eq "all" -or $Svc -eq "api-admin") { return "thebet365-images-$ImageTag.tar" }
return "thebet365-$Svc-$ImageTag.tar"
}
function Assert-ImageTag {
param([string]$Value)
if ($Value -notmatch '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') {
throw "镜像 tag 不合法: $Value"
throw "?? tag ???? $Value"
}
}
if (-not (Test-Path $ComposeFile)) {
throw "未找到 $ComposeFile(当前目录: $Root"
throw "????$ComposeFile?????? $Root??
}
if (-not (Test-Path $EnvFile)) {
if (Test-Path ".env.docker.example") {
Write-Warning "未找到 .env.docker使用 .env.docker.example(生产请复制并修改密钥)"
Write-Warning "????.env.docker????.env.docker.example????????????"
$EnvFile = ".env.docker.example"
} else {
throw "未找到 .env.docker 或 .env.docker.example"
throw "????.env.docker ??.env.docker.example"
}
}
@@ -56,7 +60,11 @@ if ([string]::IsNullOrWhiteSpace($Tag)) {
}
Assert-ImageTag $Tag
$BuildServices = if ($Service -eq "all") { $AllServices } else { @($Service) }
$BuildServices = switch ($Service) {
"all" { $AllServices }
"api-admin" { @("api", "admin") }
default { @($Service) }
}
$SaveImages = $BuildServices | ForEach-Object { "thebet365-${_}:${Tag}" }
if ([string]::IsNullOrWhiteSpace($Output)) {
@@ -64,7 +72,7 @@ if ([string]::IsNullOrWhiteSpace($Output)) {
}
if (-not $ExportOnly) {
Write-Host "==> 构建镜像: $($BuildServices -join ', ') (tag: $Tag)"
Write-Host "==> ????: $($BuildServices -join ', ') (tag: $Tag)"
$buildArgs = @(
"compose", "-f", $ComposeFile, "--env-file", $EnvFile, "build"
)
@@ -76,7 +84,7 @@ if (-not $ExportOnly) {
try {
$env:IMAGE_TAG = $Tag
& docker @buildArgs
if ($LASTEXITCODE -ne 0) { throw "docker build 失败,退出码 $LASTEXITCODE" }
if ($LASTEXITCODE -ne 0) { throw "docker build ?????? $LASTEXITCODE" }
} finally {
$env:IMAGE_TAG = $oldImageTag
}
@@ -84,9 +92,9 @@ if (-not $ExportOnly) {
$OutputPath = if ([System.IO.Path]::IsPathRooted($Output)) { $Output } else { Join-Path $Root $Output }
Write-Host "==> 导出镜像 ($Service) -> $OutputPath"
Write-Host "==> ???? ($Service) -> $OutputPath"
& docker save @SaveImages -o $OutputPath
if ($LASTEXITCODE -ne 0) { throw "docker save 失败,退出码 $LASTEXITCODE" }
if ($LASTEXITCODE -ne 0) { throw "docker save ?????? $LASTEXITCODE" }
$manifestPath = if ($OutputPath.EndsWith(".tar")) {
$OutputPath.Substring(0, $OutputPath.Length - 4) + ".manifest.txt"
@@ -117,7 +125,7 @@ $manifestLines = @(
)
foreach ($svc in $BuildServices) {
$imageId = (& docker image inspect "thebet365-${svc}:${Tag}" --format "{{.Id}}").Trim()
if ($Service -eq "all") {
if ($Service -eq "all" -or $Service -eq "api-admin") {
$manifestLines += "${svc}_image=thebet365-${svc}:${Tag}"
$manifestLines += "${svc}_image_id=$imageId"
} else {
@@ -128,13 +136,17 @@ foreach ($svc in $BuildServices) {
$manifestLines | Set-Content -Encoding UTF8 $manifestPath
$sizeMb = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
Write-Host "完成: $OutputPath (${sizeMb} MB)"
Write-Host "??: $OutputPath (${sizeMb} MB)"
Write-Host "Manifest: $manifestPath"
$recreate = if ($Service -eq "all") { "api player admin" } else { $Service }
$recreate = switch ($Service) {
"all" { "api player admin" }
"api-admin" { "api admin" }
default { $Service }
}
Write-Host @"
:
????????:
docker load -i $([System.IO.Path]::GetFileName($OutputPath))
docker compose -f docker-compose.prod.yml --env-file .env.docker up -d --force-recreate $recreate
"@

View File

@@ -25,7 +25,7 @@ default_tag() { echo "latest"; }
default_output() {
local svc="$1" image_tag="$2"
if [[ "$svc" == "all" ]]; then
if [[ "$svc" == "all" || "$svc" == "api-admin" ]]; then
echo "thebet365-images-${image_tag}.tar"
else
echo "thebet365-${svc}-${image_tag}.tar"
@@ -39,8 +39,8 @@ validate_tag() {
validate_service() {
case "$1" in
all|api|player|admin) ;;
*) echo "错误: --service 无效: $1(可选 api / player / admin / all" >&2; exit 1 ;;
all|api|player|admin|api-admin) ;;
*) echo "错误: --service 无效: $1(可选 api / player / admin / api-admin / all" >&2; exit 1 ;;
esac
}
@@ -49,7 +49,7 @@ usage() {
用法: docs/docker/build-and-export-images.sh [选项]
选项:
--service SVC api | player | admin | all默认 all
--service SVC api | player | admin | api-admin | all默认 all
--tag TAG 镜像 tag默认 latest
--use-cache 构建时使用 Docker 缓存(默认 --no-cache
--export-only 跳过构建,仅导出已有指定 tag 镜像
@@ -105,6 +105,8 @@ OUTPUT="${OUTPUT:-$(default_output "$SERVICE" "$TAG")}"
if [[ "$SERVICE" == "all" ]]; then
BUILD_SERVICES=(api player admin)
elif [[ "$SERVICE" == "api-admin" ]]; then
BUILD_SERVICES=(api admin)
else
BUILD_SERVICES=("$SERVICE")
fi
@@ -161,7 +163,7 @@ fi
echo "tar=$(basename "$OUTPUT_PATH")"
echo "tar_sha256=$CHECKSUM"
for svc in "${BUILD_SERVICES[@]}"; do
if [[ "$SERVICE" == "all" ]]; then
if [[ "$SERVICE" == "all" || "$SERVICE" == "api-admin" ]]; then
echo "${svc}_image=thebet365-${svc}:$TAG"
echo "${svc}_image_id=$(docker image inspect "thebet365-${svc}:$TAG" --format '{{.Id}}')"
else
@@ -182,6 +184,8 @@ echo "Manifest: $MANIFEST_PATH"
RECREATE="$SERVICE"
if [[ "$SERVICE" == "all" ]]; then
RECREATE="api player admin"
elif [[ "$SERVICE" == "api-admin" ]]; then
RECREATE="api admin"
fi
cat <<EOF

View File

@@ -0,0 +1,30 @@
@echo off
REM Build and export main player theme only.
REM Automatically switches to 'main' branch, builds, and restores original branch.
REM Usage: docs\docker\build-and-export-player-main.bat [options]
cd /d "%~dp0..\.."
set "BRANCH=main"
set "TAG=main"
set "ORIGINAL_BRANCH="
for /f "delims=" %%B in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "ORIGINAL_BRANCH=%%B"
if not defined ORIGINAL_BRANCH (
echo ERROR: Cannot determine current Git branch.
exit /b 1
)
echo [INFO] Switching to branch %BRANCH%...
git checkout %BRANCH% >nul 2>&1
if errorlevel 1 (
echo [ERROR] Cannot switch to branch %BRANCH%
exit /b 1
)
call docs\docker\build-and-export-images.bat --service player --tag %TAG% %*
set "BUILD_STATUS=%ERRORLEVEL%"
echo [INFO] Restoring original branch: %ORIGINAL_BRANCH%
git checkout %ORIGINAL_BRANCH% >nul 2>&1
exit /b %BUILD_STATUS%

View File

@@ -0,0 +1,30 @@
@echo off
REM Build and export theme-2 player theme only.
REM Automatically switches to 'theme-2' branch, builds, and restores original branch.
REM Usage: docs\docker\build-and-export-player-theme-2.bat [options]
cd /d "%~dp0..\.."
set "BRANCH=theme-2"
set "TAG=theme-2"
set "ORIGINAL_BRANCH="
for /f "delims=" %%B in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "ORIGINAL_BRANCH=%%B"
if not defined ORIGINAL_BRANCH (
echo ERROR: Cannot determine current Git branch.
exit /b 1
)
echo [INFO] Switching to branch %BRANCH%...
git checkout %BRANCH% >nul 2>&1
if errorlevel 1 (
echo [ERROR] Cannot switch to branch %BRANCH%
exit /b 1
)
call docs\docker\build-and-export-images.bat --service player --tag %TAG% %*
set "BUILD_STATUS=%ERRORLEVEL%"
echo [INFO] Restoring original branch: %ORIGINAL_BRANCH%
git checkout %ORIGINAL_BRANCH% >nul 2>&1
exit /b %BUILD_STATUS%

View File

@@ -0,0 +1,30 @@
@echo off
REM Build and export theme-3 player theme only.
REM Automatically switches to 'theme-3' branch, builds, and restores original branch.
REM Usage: docs\docker\build-and-export-player-theme-3.bat [options]
cd /d "%~dp0..\.."
set "BRANCH=theme-3"
set "TAG=theme-3"
set "ORIGINAL_BRANCH="
for /f "delims=" %%B in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "ORIGINAL_BRANCH=%%B"
if not defined ORIGINAL_BRANCH (
echo ERROR: Cannot determine current Git branch.
exit /b 1
)
echo [INFO] Switching to branch %BRANCH%...
git checkout %BRANCH% >nul 2>&1
if errorlevel 1 (
echo [ERROR] Cannot switch to branch %BRANCH%
exit /b 1
)
call docs\docker\build-and-export-images.bat --service player --tag %TAG% %*
set "BUILD_STATUS=%ERRORLEVEL%"
echo [INFO] Restoring original branch: %ORIGINAL_BRANCH%
git checkout %ORIGINAL_BRANCH% >nul 2>&1
exit /b %BUILD_STATUS%

View File

@@ -0,0 +1,30 @@
@echo off
REM Build and export theme-4 player theme only.
REM Automatically switches to 'theme-4' branch, builds, and restores original branch.
REM Usage: docs\docker\build-and-export-player-theme-4.bat [options]
cd /d "%~dp0..\.."
set "BRANCH=theme-4"
set "TAG=theme-4"
set "ORIGINAL_BRANCH="
for /f "delims=" %%B in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "ORIGINAL_BRANCH=%%B"
if not defined ORIGINAL_BRANCH (
echo ERROR: Cannot determine current Git branch.
exit /b 1
)
echo [INFO] Switching to branch %BRANCH%...
git checkout %BRANCH% >nul 2>&1
if errorlevel 1 (
echo [ERROR] Cannot switch to branch %BRANCH%
exit /b 1
)
call docs\docker\build-and-export-images.bat --service player --tag %TAG% %*
set "BUILD_STATUS=%ERRORLEVEL%"
echo [INFO] Restoring original branch: %ORIGINAL_BRANCH%
git checkout %ORIGINAL_BRANCH% >nul 2>&1
exit /b %BUILD_STATUS%

View File

@@ -14,8 +14,14 @@
|------|----------|
| `docs/docker/build-and-export-images.bat` | WindowsCMD构建全部 |
| `docs/docker/build-and-export-api.bat` | Windows仅 api可双击 |
| `docs/docker/build-and-export-player.bat` | Windows仅 player可双击 |
| `docs/docker/build-and-export-player.bat` | Windows单分支 player可双击 |
| `docs/docker/build-and-export-player-main.bat` | Windows仅主站/暗金主题,自动切分支) |
| `docs/docker/build-and-export-player-theme-2.bat` | Windows仅 theme-2 蓝白主题,自动切分支) |
| `docs/docker/build-and-export-player-theme-3.bat` | Windows仅 theme-3 移动主题,自动切分支) |
| `docs/docker/build-and-export-player-theme-4.bat` | Windows仅 theme-4 海军蓝极简,自动切分支) |
| `docs/docker/build-and-export-admin.bat` | Windows仅 admin可双击 |
| `docs/docker/build-and-export-all-themes.ps1` | Windows**四套主题 player 一键打包**,自动切分支,推荐) |
| `docs/docker/build-and-export-all-themes.bat` | Windows同上薄包装启动器双击可用 |
| `docs/docker/build-and-export-images.ps1` | WindowsPowerShell |
| `docs/docker/build-and-export-images.sh` | Linux / macOS / Git Bash |
@@ -118,6 +124,81 @@ docs\docker\build-and-export-images.bat --export-only
---
## 三、四套主题玩家端打包
> 关联文档:[四套主题Docker部署任务.md](../四套主题Docker部署任务.md)「阶段 C」
### 一键打包(推荐)
脚本会自动:切到各主题分支 → 构建 player 镜像 → 导出 tar → 切回原分支 → 构建 api/admin。
> **为何用 PowerShell** 本脚本仅存在于 `main` 分支;执行过程中会 `git checkout theme-*`CMD 批处理会从磁盘逐行读取切分支后脚本文件消失会导致中断。PowerShell 启动时已将整份脚本载入内存,可安全跨分支执行。`.bat` 仅为薄包装,内部转调 `.ps1`。
```powershell
cd C:\path\to\thebet365
.\docs\docker\build-and-export-all-themes.ps1
```
或双击 / CMD
```bat
docs\docker\build-and-export-all-themes.bat
```
可选参数PowerShell 与 bat 均支持):
| 参数 | 说明 |
|------|------|
| `-UseCache` / `--use-cache` | 使用 Docker 层缓存,加快重复构建速度 |
| `-ExportOnly` / `--export-only` | 跳过构建,仅导出本地已有镜像 |
| `-SkipApiAdmin` / `--skip-api-admin` | 只打四套 player跳过 api/admin |
| `-SkipBundle` / `--skip-bundle` | 跳过六合一包,仅保留分散 tar |
### 产物
| tar 文件 | 加载后镜像名 |
|----------|----------------|
| `thebet365-player-main.tar` | `thebet365-player:main` |
| `thebet365-player-theme-2.tar` | `thebet365-player:theme-2` |
| `thebet365-player-theme-3.tar` | `thebet365-player:theme-3` |
| `thebet365-player-theme-4.tar` | `thebet365-player:theme-4` |
| `thebet365-images-latest.tar` | `thebet365-api:latest` + `thebet365-admin:latest` |
| **`thebet365-full-themes-latest.tar`** | **上面全部 6 个镜像(上传这一个即可)** |
也可单独导出六合一包(本地镜像已齐时):
```bat
docs\docker\build-and-export-images.bat --service full-themes --export-only --tag latest
```
### 分支要求
- 执行前确保 `main``theme-2``theme-3``theme-4` 四个分支在本地均已拉取
- 工作区若有未提交变更,脚本会给出 WARN建议先 `git stash` 后再运行
- 若某一分支切换失败,该分支会被跳过并标记失败,其余分支仍继续构建
### 手动逐一打包(有明确失败时)
```bat
git checkout main
docs\docker\build-and-export-images.bat --service player --tag main
git checkout theme-2
docs\docker\build-and-export-images.bat --service player --tag theme-2
git checkout theme-3
docs\docker\build-and-export-images.bat --service player --tag theme-3
git checkout theme-4
docs\docker\build-and-export-images.bat --service player --tag theme-4
REM 最后打 api + admin
git checkout main
docs\docker\build-and-export-images.bat --tag latest
```
---
## 四、构建产物
| 镜像名 | 说明 |
@@ -139,6 +220,8 @@ docs\docker\build-and-export-images.bat --export-only
## 五、上传到服务器并部署
完整分步说明(本地打包 → 上传 → 终端执行 → 验证)见上级文档 **[Docker部署指南.md 第八节](../Docker部署指南.md#八推荐发版流程本地打包--上传--线上更新)**。
### 1. 上传
将以下内容传到服务器同一目录(如 `/www/wwwroot/thebet365`

View File

@@ -0,0 +1,605 @@
# 四套主题 Docker 生产部署任务
> **目标**:在同一台服务器、**同一套 Docker Compose 栈**内,同时运行 4 套玩家端主题4 个 player 容器 + 1 套共用 api/admin/db
> **适用分支**`main`(暗金)、`theme-2`Pinnacle 蓝白)、`theme-3`(统一移动端视觉)、`theme-4`(海军蓝暗色极简)。
> **关联文档**[Docker部署指南.md](./Docker部署指南.md)、[docker/镜像构建与导出.md](./docker/镜像构建与导出.md)、[AGENTS.md](../AGENTS.md)「主题分支」章节。
---
## 一、架构总览
```text
域名 A主站 → 127.0.0.1:8082 → thebet365-player 镜像 tag: main
域名 Btheme-2 → 127.0.0.1:8083 → thebet365-player2 镜像 tag: theme-2
域名 Ctheme-3 → 127.0.0.1:8084 → thebet365-player3 镜像 tag: theme-3
域名 Dtheme-4 → 127.0.0.1:8085 → thebet365-player4 镜像 tag: theme-4
管理后台 → 127.0.0.1:8081 → thebet365-admin 镜像 tag: latest或统一 IMAGE_TAG
thebet365-apiDocker 内网,不映射宿主机端口)
thebet365-postgres + thebet365-redis
```
| 要点 | 说明 |
|------|------|
| 不是「一个容器四套皮肤」 | 是 **4 个 player 容器**,各跑各自构建好的静态资源 |
| 后端共用 | api / postgres / redis / uploads **只有一套**,账号与余额互通 |
| 本地开发 | 仍只需 `pnpm dev:player``:5173`**切分支**预览不同主题;不必本地开 4 端口 |
| `deploy-update.sh` | 默认只维护 **一套** api/player/admin多主题 player2~4 需 **单独 load + compose up** |
| 邀请注册链接 | 当前仅 `VITE_PLAYER_URL` 单域名;需 **阶段 F** 实现后台按主题选链接(见第十三节) |
---
## 二、任务清单(实施顺序)
按顺序勾选,下次上线可直接照着做。
### 阶段 A — 仓库改动(一次性)
- [x] **A1** 新增 `docker-compose.themes.yml`player2 / player3 / player4 服务定义)
- [x] **A2** 更新 `.env.docker.example``PLAYER2_*` ~ `PLAYER4_*``CORS_ORIGINS` 示例)
- [x] **A3** 更新 `docker-compose.prod.yml``player` 使用 `PLAYER_IMAGE_TAG`(与 `IMAGE_TAG` 解耦,见下文片段)
- [x] **A4**(可选)在 `docs/Docker部署指南.md` 增加「多主题」小节链接到本文
- [x] **A5**(可选)在 `AGENTS.md` 文档索引增加本文链接
### 阶段 B — 各主题分支同步 main 功能(发版前每个 theme 分支各做一遍)
- [ ] **B1**`theme-2` / `theme-3` / `theme-4` 同步 API、Admin、shared、迁移**禁止**整目录覆盖 player
- [ ] **B2** Player 侧只做逻辑/i18n **增量合并**,保留各分支 `styles.css` 与主题资源
- [ ] **B3** 各分支本地 `pnpm build``pnpm dev:player` 冒烟
- [ ] **B4** `main` 分支照常维护;若 api/admin 有变更,四个 player 镜像可共用同一 api/admin 包
### 阶段 C — 本地打镜像Windows 构建机)
- [ ] **C1** 四个分支分别打 **player** 镜像tag 互不相同)
- [ ] **C2****api + admin** 一包(在 `main` 或任意已同步分支tag 如 `latest`
- [ ] **C3** 核对 `.manifest.txt``git_commit` 与分支一致
- [ ] **C4** 上传 tar 到服务器(勿覆盖 `.env.docker`
### 阶段 D — 服务器首次启用多主题
- [ ] **D1** 备份:执行 `deploy-update` 前会自动备份;首次改 compose 前建议手动 `./scripts/backup-prod.sh`
- [ ] **D2** `docker load` 四个 player tar + api/admin tar
- [ ] **D3** 编辑 `.env.docker`(端口、镜像 tag、CORS、域名
- [ ] **D4** `docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d`
- [ ] **D5** 宝塔为 4 个玩家域名 + 1 个管理域名配置反代
- [ ] **D6** 执行下方「验收清单」
### 阶段 E — 日常更新(重复)
- [ ] 仅换某一主题 UI → 只 rebuild/load 对应 player tag → `up -d playerN`
- [ ] API/Admin/迁移变更 → `deploy-update.sh` 更新 api/admin必要时四个 player 无需重建
- [ ] 发版后强刷浏览器 / 清 CDN
### 阶段 F — 邀请链接按主题选择(功能开发,可与 D 并行)
- [ ] **F1** API`SystemConfig` 存四套玩家站域名配置(见第十三节)
- [ ] **F2** 管理端「全局设置」可编辑主题名称 + 公网 URL + 默认项
- [ ] **F3** 邀请面板 / 邀请历史:下拉选择主题后再复制注册链接
- [ ] **F4** 代理端邀请弹窗同样可读主题列表并选择(只读,不能改 URL
- [ ] **F5** 部署四套主题后,在全局设置填齐 4 个 https 域名并验收邀请链接
---
## 三、仓库待实现:`docker-compose.themes.yml`
> **任务 A1**:在仓库根目录新建此文件,与 `docker-compose.prod.yml` 合并使用。
```yaml
# 四套主题扩展 — 与 docker-compose.prod.yml 一起使用:
# docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d
services:
player2:
image: thebet365-player:${PLAYER2_IMAGE_TAG:-theme-2}
container_name: thebet365-player2
depends_on:
api:
condition: service_healthy
ports:
- '${BIND_ADDR:-127.0.0.1}:${PLAYER2_PORT:-8083}:80'
healthcheck:
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1']
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
restart: unless-stopped
networks:
- thebet365
player3:
image: thebet365-player:${PLAYER3_IMAGE_TAG:-theme-3}
container_name: thebet365-player3
depends_on:
api:
condition: service_healthy
ports:
- '${BIND_ADDR:-127.0.0.1}:${PLAYER3_PORT:-8084}:80'
healthcheck:
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1']
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
restart: unless-stopped
networks:
- thebet365
player4:
image: thebet365-player:${PLAYER4_IMAGE_TAG:-theme-4}
container_name: thebet365-player4
depends_on:
api:
condition: service_healthy
ports:
- '${BIND_ADDR:-127.0.0.1}:${PLAYER4_PORT:-8085}:80'
healthcheck:
test: ['CMD-SHELL', 'wget -q -O /dev/null http://127.0.0.1/ || exit 1']
interval: 10s
timeout: 5s
retries: 6
start_period: 10s
restart: unless-stopped
networks:
- thebet365
```
### 任务 A3调整 `docker-compose.prod.yml` 的 `player` 服务
`player.image``${IMAGE_TAG}` 改为独立变量(避免与 api/admin 的 `IMAGE_TAG` 绑死):
```yaml
player:
image: thebet365-player:${PLAYER_IMAGE_TAG:-main}
# build / ports / healthcheck 等其余保持不变
```
> `player2~4` **不要**写 `build:`,生产只 load 预构建镜像。
---
## 四、`.env.docker` 配置模板
> **任务 A2**:追加到 `.env.docker.example`;服务器 `.env.docker` 按真实域名填写。
```env
# ── 共用后端 ──
IMAGE_TAG=latest
ADMIN_PORT=8081
BIND_ADDR=127.0.0.1
# 管理端邀请链接默认指向的主玩家站(勿带末尾 /
VITE_PLAYER_URL=https://www.example.com
# 四个玩家站域名都需列入(逗号分隔,无空格或统一 trim
CORS_ORIGINS=https://www.example.com,https://theme2.example.com,https://theme3.example.com,https://theme4.example.com,https://admin.example.com
# ── 四套 player 镜像 tag 与宿主机端口 ──
PLAYER_IMAGE_TAG=main
PLAYER_PORT=8082
PLAYER2_IMAGE_TAG=theme-2
PLAYER2_PORT=8083
PLAYER3_IMAGE_TAG=theme-3
PLAYER3_PORT=8084
PLAYER4_IMAGE_TAG=theme-4
PLAYER4_PORT=8085
```
修改 `CORS_ORIGINS` 后必须重启 api
```bash
docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker restart api
```
---
## 五、分支合并代码main → theme-*
> **发版前**在每个 `theme-*` 分支执行。目标:功能与 main 一致,**皮肤不变**。
### 5.1 禁止事项
| 禁止 | 原因 |
|------|------|
| `git merge main` 后直接发 player | 易把 main 暗金样式大量带入 |
| `git checkout main -- apps/player/` | 整目录覆盖会冲掉主题 CSS/组件 |
| `git checkout main -- apps/player/src/i18n/` | 会冲掉主题分支已有措辞 |
### 5.2 推荐整目录同步API / Admin / shared / 迁移)
在目标主题分支上(示例 `theme-2`
```bash
git checkout theme-2
git fetch origin
# 同步后端与共享包(可按需增减路径)
git checkout origin/main -- apps/api
git checkout origin/main -- apps/admin
git checkout origin/main -- packages/shared
git checkout origin/main -- pnpm-lock.yaml
git checkout origin/main -- pnpm-workspace.yaml
# 若有新迁移,务必带上
git checkout origin/main -- apps/api/prisma/migrations
git checkout origin/main -- apps/api/prisma/schema.prisma
git status # 确认没有误改 apps/player 大段样式文件
git commit -m "sync: api/admin/shared/migrations from main"
```
### 5.3 Player只做逻辑与文案增量
**方式 1 — 按文件 cherry-pick有明确 commit 时)**
```bash
git log origin/main --oneline -- apps/player/src/composables apps/player/src/stores
git cherry-pick <commit-hash> # 冲突时保留 theme 分支的 styles / 主题组件
```
**方式 2 — 按目录选择性 checkout仅非 UI 目录)**
```bash
# 示例:只同步 stores、composables、api 封装(执行前确认路径在 main 有变更)
git checkout origin/main -- apps/player/src/stores
git checkout origin/main -- apps/player/src/composables
git checkout origin/main -- apps/player/src/api
```
**方式 3 — i18n 只合并新增 key**
- 对比 `apps/player/src/i18n/*.ts`,手工把 main **新增的 key** 补进三语文件
- 不要用 main 文件整文件覆盖
### 5.4 合并后本地验证(每个 theme 分支)
```bash
pnpm install
pnpm db:generate # 若 schema 有变
pnpm --filter @thebet365/shared build
pnpm --filter @thebet365/player build
pnpm dev:api # 另开终端
pnpm dev:player # http://localhost:5173 目视确认仍是本主题皮肤
```
### 5.5 main 分支
- 日常功能开发在 `main` 完成后再同步到各 theme 分支
- `main` 自身 player 镜像 tag 建议使用 `main``latest`(与 `.env.docker``PLAYER_IMAGE_TAG` 一致即可)
---
## 六、构建与上传镜像
### 6.1 四个 player 镜像
**一键打包(推荐,仅 `main` 分支提供脚本):**
```powershell
cd C:\path\to\thebet365
.\docs\docker\build-and-export-all-themes.ps1 -SkipApiAdmin
```
`docs\docker\build-and-export-all-themes.bat --skip-api-admin`(内部转调 PowerShell避免切分支后 bat 从磁盘消失)。
**手动逐步Windows CMD**
```bat
cd C:\path\to\thebet365
git checkout main
docs\docker\build-and-export-player.bat --tag main
git checkout theme-2
docs\docker\build-and-export-player.bat --tag theme-2
git checkout theme-3
docs\docker\build-and-export-player.bat --tag theme-3
git checkout theme-4
docs\docker\build-and-export-player.bat --tag theme-4
```
产物(项目根目录,已在 `.gitignore`
| 文件 | 加载后镜像名 |
|------|----------------|
| `thebet365-player-main.tar` | `thebet365-player:main` |
| `thebet365-player-theme-2.tar` | `thebet365-player:theme-2` |
| `thebet365-player-theme-3.tar` | `thebet365-player:theme-3` |
| `thebet365-player-theme-4.tar` | `thebet365-player:theme-4` |
### 6.2 api + admin 一包
```bat
git checkout main
docs\docker\build-and-export-images.bat --tag latest
```
或 Linux`./docs/docker/build-and-export-images.sh --tag latest`
### 6.3 上传至服务器
目录示例:`/www/wwwroot/thebet365`
| 必传 | 说明 |
|------|------|
| 四个 player tar | 四套皮肤 |
| `thebet365-images-latest.tar`(或带版本 tag | api + admin |
| `docker-compose.prod.yml` | 若仓库有更新 |
| `docker-compose.themes.yml` | **首次多主题必传** |
| `scripts/` | 若部署脚本有更新 |
**勿覆盖**`.env.docker`、Docker 数据卷、`backups/`
---
## 七、服务器部署命令
### 7.1 首次启用四套主题
```bash
cd /www/wwwroot/thebet365
chmod +x scripts/*.sh
# 导入全部镜像
docker load -i thebet365-images-latest.tar
docker load -i thebet365-player-main.tar
docker load -i thebet365-player-theme-2.tar
docker load -i thebet365-player-theme-3.tar
docker load -i thebet365-player-theme-4.tar
# 编辑 .env.docker见第四节模板后启动
docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d
```
若当前环境已由 `deploy-first.sh` 跑通,只需 **load 新 player 镜像 + 合并 compose + up -d player2 player3 player4**,并调整 `.env.docker`
### 7.2 更新共用 api/admin
仍用现有脚本(**不会**自动更新 player2~4
```bash
./scripts/deploy-update.sh --images thebet365-images-latest.tar --tag latest
```
之后确认多主题 compose 仍生效:
```bash
docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d
```
### 7.3 只更新某一主题(例如 theme-4
```bash
docker load -i thebet365-player-theme-4.tar
docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d player4
```
其他主题容器不受影响。
### 7.4 回滚某一 player
```bash
# 加载旧 tag 的 tar 后
docker compose -f docker-compose.prod.yml -f docker-compose.themes.yml --env-file .env.docker up -d player3
```
api 回滚仍用 `./scripts/rollback.sh --to <tag>`**不**回滚数据库)。
---
## 八、宝塔 / Nginx 反代
每个玩家站 **只反代到对应端口**,容器内已处理 `/api``/uploads`,宝塔无需再写 API 规则。
| 宝塔网站 | `proxy_pass` |
|----------|----------------|
| 主站main | `http://127.0.0.1:8082` |
| theme-2 站 | `http://127.0.0.1:8083` |
| theme-3 站 | `http://127.0.0.1:8084` |
| theme-4 站 | `http://127.0.0.1:8085` |
| 管理后台 | `http://127.0.0.1:8081` |
示例:
```nginx
location / {
proxy_pass http://127.0.0.1:8082;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
四套站点分别申请 SSL`CORS_ORIGINS` 使用 **https** 域名。
---
## 九、验收清单
部署完成后逐项检查:
- [ ] `docker compose ... ps` — api、admin、player、player2、player3、player4 均为 **healthy**
- [ ] 四个玩家域名首页 UI 皮肤互不相同
- [ ] 各站登录同一账号,余额一致
- [ ] 各站下注、充值、站内信、公告正常
- [ ] 管理后台 `:8081` 正常;员工菜单与 Inbox 开关已配置(见 [Docker部署指南.md](./Docker部署指南.md) 第八节步骤 5
- [ ] `npx prisma migrate status`(在 api 容器内)无 pending 迁移
- [ ] 浏览器 Network 无 CORS 报错(若有,检查 `CORS_ORIGINS` 并 restart api
- [ ] theme-4 独有组件(如悬浮客服)仅在 theme-4 站出现
- [ ] **阶段 F 完成后**:邀请面板切换四套主题,复制链接分别打开对应域名 `/register?code=...`
---
## 十、常见问题
| 现象 | 处理 |
|------|------|
| 某站 502 | `docker compose ps` 看对应 player 是否 healthy`docker logs thebet365-player3` |
| 接口 401/403 仅某一域名 | 检查 `CORS_ORIGINS` 是否包含该 https 域名 |
| 四套变成同一皮肤 | 检查是否四个 tag 打错或 `.env.docker``PLAYER*_IMAGE_TAG` 写错 |
| `deploy-update` 后 player2 消失 | 脚本未管理 themes compose重新 `up -d` 并带 `-f docker-compose.themes.yml` |
| 邀请链接域名不对 | **未做阶段 F**:改 `VITE_PLAYER_URL` 重建 admin仅一个默认站**已做阶段 F**:到「全局设置 → 玩家主题站点」核对 URL邀请面板选对应主题 |
| 构建 ENOENT `public/球员` | 清理 `packages/shared/public` 下除 `flags``players` 外的中文目录后重试 |
---
## 十一、与本地开发的区别
| 场景 | 做法 |
|------|------|
| 日常改某一主题 UI | 切到对应分支 → `pnpm dev:player`5173 |
| 本地同时对比 4 套 | 可选:多开终端 `--port 5173/5174/5175/5176`(非必须) |
| 生产 4 套并存 | 4 个 player 容器 + 4 个端口 + 4 个域名 |
---
## 十二、邀请链接按主题选择(阶段 F 详细设计)
> **现状**`apps/admin/src/utils/invite-link.ts` 用构建时注入的 `VITE_PLAYER_URL` 拼链接;四套主题并存时后台无法选择注册落地页。
> **目标**:管理员(及代理)生成/复制邀请链接时,**下拉选择**要落地的玩家主题站;配置存数据库,**改域名不必重建 admin 镜像**。
### 12.1 行为说明
| 角色 | 能力 |
|------|------|
| 平台管理员(`settings.manage` | 在「全局设置」维护主题列表:显示名、公网 URL、是否启用、哪一项为默认 |
| 管理员 / 代理 | 在「邀请」弹窗选主题 → 复制链接 `{所选站}/register?code=xxx` |
| 玩家 | 无感知;任意主题站注册,同一邀请码、同一后端 |
邀请码本身与主题无关(仍走现有 `InvitesService`**仅注册链接里的域名**随所选主题变化。
### 12.2 数据模型(`SystemConfig`
配置键:`player.theme_sites`
JSON 字符串,结构示例:
```json
{
"themes": [
{ "id": "main", "label": "暗金主站", "baseUrl": "https://www.example.com", "enabled": true, "isDefault": true },
{ "id": "theme-2", "label": "Pinnacle 蓝白", "baseUrl": "https://theme2.example.com", "enabled": true, "isDefault": false },
{ "id": "theme-3", "label": "统一移动端", "baseUrl": "https://theme3.example.com", "enabled": true, "isDefault": false },
{ "id": "theme-4", "label": "海军蓝极简", "baseUrl": "https://theme4.example.com", "enabled": true, "isDefault": false }
]
}
```
规则:
- `id` 与 Git 分支 / Docker 镜像 tag 对齐(`main``theme-2``theme-3``theme-4`
- `baseUrl` **勿带末尾 `/`**;保存时 API 侧 trim + 校验 `https?://`
- 有且仅有一个 `isDefault: true`;未配置时 API 回退 `VITE_PLAYER_URL`(兼容旧环境)
- `enabled: false` 的主题不出现在邀请下拉里
### 12.3 API 任务(`apps/api`
**F1 — `SystemConfigService` 新增:**
```typescript
// apps/api/src/shared/config/system-config.service.ts
getPlayerThemeSites(): Promise<PlayerThemeSitesSettings>
updatePlayerThemeSites(data): Promise<PlayerThemeSitesSettings>
listEnabledPlayerThemes(): Promise<PlayerThemePublicOption[]> // 仅 id/label/baseUrl/isDefault
```
**F1 — Admin 控制器:**
| 方法 | 路径 | 权限 | 说明 |
|------|------|------|------|
| GET | `/admin/settings/player-themes` | `settings.manage` | 完整配置(含 disabled |
| PUT | `/admin/settings/player-themes` | `settings.manage` | 保存;写审计 `UPDATE_PLAYER_THEME_SITES` |
| GET | `/admin/player-themes/options` | 登录员工/代理即可 | 仅 `enabled` 主题,供邀请 UI |
代理门户若走 `/manage/*`,在 `auth.controller` 或 manage 控制器增加同等 **GET options**(复用 service
**校验:**
- 至少保留 1 个 `enabled` 主题
- URL 格式合法;禁止重复 `baseUrl`
- 更新后无需重启 api读 DB
### 12.4 管理端任务(`apps/admin`
**F2 — `GlobalSettingsView.vue` 增加卡片「玩家主题站点」:**
- 表格编辑label、baseUrl、enabled、设为默认
- 保存调用 `PUT /admin/settings/player-themes`
- 与第四节四个域名保持一致(部署后首次必填)
**F3 — 邀请 UI**
涉及文件:
- `apps/admin/src/utils/invite-link.ts` — 改为 `buildPlayerRegisterUrl(code, baseUrl?)`
- `apps/admin/src/components/InviteCodePanel.vue` — 加载 options`el-select` 选主题,`registerUrl` 随选择变化
- `apps/admin/src/components/InviteHistoryPanel.vue` — 复制链接前同样选主题(或记住上次选择)
交互建议:
- 默认选中 API 返回的 `isDefault` 主题
- `localStorage``admin_invite_theme_id` 记住上次选择(可选)
- 无配置时回退 `VITE_PLAYER_URL`,与现网行为一致
**F4 — i18n三语同步**
`admin-messages.ts` / `admin-pages*.ts` 增加 key例如
- `invite.theme_site` — 注册落地主题
- `invite.theme_site_hint` — 选择玩家看到的注册页面风格
- `settings.player_themes` — 玩家主题站点
- `settings.player_themes_hint` — 与四套 Docker 玩家域名对应
### 12.5 与 Docker 四套主题的对应关系
部署完四套 player 后,在 **全局设置** 填:
| id | 对应容器 | 示例 baseUrl |
|----|----------|--------------|
| `main` | `thebet365-player` :8082 | `https://www.example.com` |
| `theme-2` | `thebet365-player2` :8083 | `https://theme2.example.com` |
| `theme-3` | `thebet365-player3` :8084 | `https://theme3.example.com` |
| `theme-4` | `thebet365-player4` :8085 | `https://theme4.example.com` |
`VITE_PLAYER_URL` 仍可保留为 **构建默认值****未配置 DB 时的回退**;多主题上线后以 DB 配置为准。
### 12.6 阶段 F 验收
- [ ] 全局设置保存 4 个 URL 后刷新仍生效
- [ ] 邀请面板切换主题,链接域名随之变化,邀请码不变
- [ ] 四套链接均可打开注册页并带 `code` 参数
- [ ] 代理账号邀请弹窗可选主题(不能进全局设置改 URL
- [ ] 禁用某主题后下拉里消失
- [ ] 未配置 DB 时行为与现网一致(`VITE_PLAYER_URL`
### 12.7 实施顺序建议
```text
F1 API 配置读写 → F2 全局设置页 → F3 邀请面板 → F4 代理端 → 部署四套主题后填 URL → 12.6 验收
```
可与 **阶段 A~DDocker 多 player** 并行开发;功能不依赖 player2~4 已上线,但验收需要四个域名可访问。
---
## 十三、文档维护
| 变更类型 | 更新本文 |
|----------|----------|
| 新增 theme-5 | 复制 player4 模式,追加端口 8086 |
| 修改 deploy 脚本支持多 player | 更新第七节命令 |
| 分支合并策略变化 | 更新第五节 |
---
**下次实施入口**
- **基础设施**:阶段 A → B → C → D → 第九节验收
- **邀请多主题**:阶段 F第十二节可与 A~D 并行,四套域名就绪后做 12.6 验收

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 205 B

After

Width:  |  Height:  |  Size: 157 KiB

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',
@@ -97,6 +112,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Cannot unpublish league after outright market is settled',
'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan',
},
LEAGUE_OUTRIGHT_SETTLED: {
'zh-CN': '优胜赛已结算,不可再新增单场',
'en-US': 'Outright market is settled; new fixtures cannot be added',
'ms-MY': 'Pasaran juara telah diselesaikan; perlawanan baharu tidak boleh ditambah',
},
MATCH_UNPUBLISH_FORBIDDEN: {
'zh-CN': '当前状态不可下架',
'en-US': 'Match cannot be unpublished in current status',
@@ -732,6 +752,26 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Batch already confirmed',
'ms-MY': 'Batch sudah disahkan',
},
SETTLEMENT_BATCH_STALE: {
'zh-CN': '结算批次已过期,请使用最新预览批次',
'en-US': 'Settlement batch is stale; use the latest preview batch',
'ms-MY': 'Batch penyelesaian lapuk; guna batch pratonton terkini',
},
SETTLEMENT_BET_UPDATE_FAILED: {
'zh-CN': '注单 {betNo} 结算状态更新失败,请重试',
'en-US': 'Failed to update bet {betNo} for settlement',
'ms-MY': 'Gagal mengemas kini pertaruhan {betNo} untuk penyelesaian',
},
SETTLEMENT_SCORE_INVALID: {
'zh-CN': '比分无效:半场比分不能大于全场比分',
'en-US': 'Invalid score: half-time cannot exceed full-time',
'ms-MY': 'Skor tidak sah: separuh masa tidak boleh melebihi masa penuh',
},
SETTLEMENT_MARKET_UNSUPPORTED: {
'zh-CN': '盘口 {marketType} 暂不支持结算',
'en-US': 'Market type {marketType} is not supported for settlement',
'ms-MY': 'Jenis pasaran {marketType} belum disokong untuk penyelesaian',
},
SCORE_NOT_FOUND: {
'zh-CN': '比分不存在',
'en-US': 'Score not found',
@@ -842,6 +882,46 @@ 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',
},
BROADCAST_NOT_FOUND: {
'zh-CN': '发送记录不存在',
'en-US': 'Broadcast record not found',
'ms-MY': 'Rekod siaran tidak dijumpai',
},
BROADCAST_TITLE_REQUIRED: {
'zh-CN': '标题不能为空',
'en-US': 'Title is required',
'ms-MY': 'Tajuk diperlukan',
},
BROADCAST_BODY_REQUIRED: {
'zh-CN': '正文不能为空',
'en-US': 'Body is required',
'ms-MY': 'Kandungan diperlukan',
},
BROADCAST_TITLE_TOO_LONG: {
'zh-CN': '标题过长(最多 256 字符)',
'en-US': 'Title is too long (max 256 characters)',
'ms-MY': 'Tajuk terlalu panjang (maks 256 aksara)',
},
BROADCAST_TARGET_USER_REQUIRED: {
'zh-CN': '请指定玩家账号',
'en-US': 'Target player username is required',
'ms-MY': 'Nama pengguna pemain sasaran diperlukan',
},
BROADCAST_NO_RECIPIENTS: {
'zh-CN': '没有可发送的玩家',
'en-US': 'No eligible recipients',
'ms-MY': 'Tiada penerima yang layak',
},
BROADCAST_CONTENT_REQUIRED: {
'zh-CN': '请至少填写一种语言的标题或正文',
'en-US': 'Provide a title or body in at least one language',
'ms-MY': 'Sila isi tajuk atau kandungan sekurang-kurangnya satu bahasa',
},
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
@@ -987,6 +1067,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'This country or region is not supported',
'ms-MY': 'Negara atau wilayah ini tidak disokong',
},
BEFORE_DATE_REQUIRED: {
'zh-CN': '截止日期不能为空',
'en-US': 'Before date is required',
'ms-MY': 'Tarikh akhir diperlukan',
},
INVALID_BEFORE_DATE: {
'zh-CN': '截止日期无效',
'en-US': 'Invalid before date',
'ms-MY': 'Tarikh akhir tidak sah',
},
BEFORE_DATE_CANNOT_BE_FUTURE: {
'zh-CN': '截止日期不能是未来日期',
'en-US': 'Before date cannot be in the future',
'ms-MY': 'Tarikh akhir tidak boleh pada masa hadapan',
},
};
export function normalizeLocale(input) {
const raw = String(input ?? '').trim();

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',
@@ -99,6 +114,11 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Cannot unpublish league after outright market is settled',
'ms-MY': 'Liga tidak boleh ditarik selepas pasaran juara diselesaikan',
},
LEAGUE_OUTRIGHT_SETTLED: {
'zh-CN': '优胜赛已结算,不可再新增单场',
'en-US': 'Outright market is settled; new fixtures cannot be added',
'ms-MY': 'Pasaran juara telah diselesaikan; perlawanan baharu tidak boleh ditambah',
},
MATCH_UNPUBLISH_FORBIDDEN: {
'zh-CN': '当前状态不可下架',
'en-US': 'Match cannot be unpublished in current status',
@@ -734,6 +754,26 @@ export const API_ERROR_MESSAGES = {
'en-US': 'Batch already confirmed',
'ms-MY': 'Batch sudah disahkan',
},
SETTLEMENT_BATCH_STALE: {
'zh-CN': '结算批次已过期,请使用最新预览批次',
'en-US': 'Settlement batch is stale; use the latest preview batch',
'ms-MY': 'Batch penyelesaian lapuk; guna batch pratonton terkini',
},
SETTLEMENT_BET_UPDATE_FAILED: {
'zh-CN': '注单 {betNo} 结算状态更新失败,请重试',
'en-US': 'Failed to update bet {betNo} for settlement',
'ms-MY': 'Gagal mengemas kini pertaruhan {betNo} untuk penyelesaian',
},
SETTLEMENT_SCORE_INVALID: {
'zh-CN': '比分无效:半场比分不能大于全场比分',
'en-US': 'Invalid score: half-time cannot exceed full-time',
'ms-MY': 'Skor tidak sah: separuh masa tidak boleh melebihi masa penuh',
},
SETTLEMENT_MARKET_UNSUPPORTED: {
'zh-CN': '盘口 {marketType} 暂不支持结算',
'en-US': 'Market type {marketType} is not supported for settlement',
'ms-MY': 'Jenis pasaran {marketType} belum disokong untuk penyelesaian',
},
SCORE_NOT_FOUND: {
'zh-CN': '比分不存在',
'en-US': 'Score not found',
@@ -844,6 +884,46 @@ 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',
},
BROADCAST_NOT_FOUND: {
'zh-CN': '发送记录不存在',
'en-US': 'Broadcast record not found',
'ms-MY': 'Rekod siaran tidak dijumpai',
},
BROADCAST_TITLE_REQUIRED: {
'zh-CN': '标题不能为空',
'en-US': 'Title is required',
'ms-MY': 'Tajuk diperlukan',
},
BROADCAST_BODY_REQUIRED: {
'zh-CN': '正文不能为空',
'en-US': 'Body is required',
'ms-MY': 'Kandungan diperlukan',
},
BROADCAST_TITLE_TOO_LONG: {
'zh-CN': '标题过长(最多 256 字符)',
'en-US': 'Title is too long (max 256 characters)',
'ms-MY': 'Tajuk terlalu panjang (maks 256 aksara)',
},
BROADCAST_TARGET_USER_REQUIRED: {
'zh-CN': '请指定玩家账号',
'en-US': 'Target player username is required',
'ms-MY': 'Nama pengguna pemain sasaran diperlukan',
},
BROADCAST_NO_RECIPIENTS: {
'zh-CN': '没有可发送的玩家',
'en-US': 'No eligible recipients',
'ms-MY': 'Tiada penerima yang layak',
},
BROADCAST_CONTENT_REQUIRED: {
'zh-CN': '请至少填写一种语言的标题或正文',
'en-US': 'Provide a title or body in at least one language',
'ms-MY': 'Sila isi tajuk atau kandungan sekurang-kurangnya satu bahasa',
},
CONTENT_ACTIVE_BANNER_INCOMPLETE: {
'zh-CN': '启用 Banner 须至少一种语言配置图片地址',
'en-US': 'ACTIVE banner requires imageUrl in at least one locale',
@@ -989,6 +1069,21 @@ export const API_ERROR_MESSAGES = {
'en-US': 'This country or region is not supported',
'ms-MY': 'Negara atau wilayah ini tidak disokong',
},
BEFORE_DATE_REQUIRED: {
'zh-CN': '截止日期不能为空',
'en-US': 'Before date is required',
'ms-MY': 'Tarikh akhir diperlukan',
},
INVALID_BEFORE_DATE: {
'zh-CN': '截止日期无效',
'en-US': 'Invalid before date',
'ms-MY': 'Tarikh akhir tidak sah',
},
BEFORE_DATE_CANNOT_BE_FUTURE: {
'zh-CN': '截止日期不能是未来日期',
'en-US': 'Before date cannot be in the future',
'ms-MY': 'Tarikh akhir tidak boleh pada masa hadapan',
},
} as const satisfies Record<string, Record<Locale, string>>;
export type ApiErrorCode = keyof typeof API_ERROR_MESSAGES;

View File

@@ -126,4 +126,5 @@ export * from './playerUsername';
export * from './initial-depositRemark';
export * from './phone-countries';
export * from './match-time';
export * from './walletTx';
export * from './api-errors';

View File

@@ -130,6 +130,7 @@ export * from './playerUsername';
export * from './initial-depositRemark';
export * from './phone-countries';
export * from './match-time';
export * from './walletTx';
export interface ApiResponse<T = unknown> {
success: boolean;

View File

@@ -0,0 +1,11 @@
/** 钱包流水展示用金额:输单结算 amount 为 0可用余额未变用冻结差额表示亏损 */
export function txDisplayAmount(tx) {
const type = tx.transactionType.toUpperCase();
const amt = parseFloat(tx.amount);
if (type === 'BET_SETTLE_LOSE' && amt === 0 && tx.frozenBefore != null && tx.frozenAfter != null) {
const frozenDelta = parseFloat(tx.frozenBefore) - parseFloat(tx.frozenAfter);
if (frozenDelta > 0)
return (-frozenDelta).toString();
}
return tx.amount;
}

View File

@@ -0,0 +1,15 @@
/** 钱包流水展示用金额:输单结算 amount 为 0可用余额未变用冻结差额表示亏损 */
export function txDisplayAmount(tx: {
transactionType: string;
amount: string;
frozenBefore?: string;
frozenAfter?: string;
}): string {
const type = tx.transactionType.toUpperCase();
const amt = parseFloat(tx.amount);
if (type === 'BET_SETTLE_LOSE' && amt === 0 && tx.frozenBefore != null && tx.frozenAfter != null) {
const frozenDelta = parseFloat(tx.frozenBefore) - parseFloat(tx.frozenAfter);
if (frozenDelta > 0) return (-frozenDelta).toString();
}
return tx.amount;
}

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

View File

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

View File

@@ -0,0 +1,16 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const logoPath = resolve(root, 'packages/shared/public/logo.png');
const outPath = resolve(root, 'packages/shared/public/favicon.svg');
const b64 = readFileSync(logoPath).toString('base64');
writeFileSync(
outPath,
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><image href="data:image/png;base64,${b64}" width="512" height="512"/></svg>`,
'utf8',
);
console.log('Wrote', outPath);

View File

@@ -0,0 +1,17 @@
import { existsSync, readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const envPath = resolve(root, 'apps/api/.env');
/** 读取 apps/api/.env 的 PORT供 Vite 代理与 dev 脚本共用 */
export function resolveDevApiPort(fallback = '3000') {
if (!existsSync(envPath)) return fallback;
const match = readFileSync(envPath, 'utf8').match(/^PORT=(\d+)/m);
return match?.[1] ?? fallback;
}
export function resolveDevApiTarget(fallbackPort = '3000') {
return `http://127.0.0.1:${resolveDevApiPort(fallbackPort)}`;
}

View File

@@ -61,12 +61,24 @@
"skillPath": "skills/minimalist-skill/SKILL.md",
"computedHash": "08873a3131d3be27bef9bf3304b310b16b44ca6e3561aebe532797be3443f6bd"
},
"receiving-code-review": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/receiving-code-review/SKILL.md",
"computedHash": "3f56080356c62e4f74a183d7371686babc661e41a952dc67a97c2c76fe8a9329"
},
"redesign-existing-projects": {
"source": "leonxlnx/taste-skill",
"sourceType": "github",
"skillPath": "skills/redesign-skill/SKILL.md",
"computedHash": "b405eee0e0e80fc243f731d9aa368bca307e356db7e6157d27101d369dac6726"
},
"requesting-code-review": {
"source": "obra/superpowers",
"sourceType": "github",
"skillPath": "skills/requesting-code-review/SKILL.md",
"computedHash": "cde520e9118d7e6b74b5ab0123cff2f68d9c07bddb3f82c997753b6126600aed"
},
"stitch-design-taste": {
"source": "leonxlnx/taste-skill",
"sourceType": "github",