Compare commits
304 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fe998adf2 | |||
| 8d8ede5397 | |||
| 68f13910a3 | |||
| 5581c117e2 | |||
| 996754be7f | |||
| 50a2829519 | |||
| 5e95e01c71 | |||
| 0d5866b82a | |||
| 2a5394155a | |||
| ef5b97530a | |||
| d932ce3dea | |||
| b8d1654476 | |||
| c5a8100c38 | |||
| 8ef64f8f42 | |||
| 1e568db861 | |||
| fe8b38bc6f | |||
| ef2a71f793 | |||
| 8664ae9fe4 | |||
| 8830650560 | |||
| 52b26fdcfb | |||
| 8c0a24fd44 | |||
| 60a0740c31 | |||
| a151f2f293 | |||
| 4352fffde7 | |||
| c4a3512f30 | |||
| 74ca0bb3d7 | |||
| 5228519c4f | |||
| f44c22c594 | |||
| e830a63deb | |||
| 4518c66a65 | |||
| a885ce0dce | |||
| 72a3443ff1 | |||
| 131d3f70a2 | |||
| de7b44aa34 | |||
| 542c370ace | |||
| c094fc7bef | |||
| 7cbdfdceab | |||
| 20113fc4bf | |||
| 1ade861aae | |||
| e3c33da40c | |||
| 3293356668 | |||
| fefdb3527d | |||
| bededd89fa | |||
| 076769c4d8 | |||
| 7d503c09e0 | |||
| 15a3e4120d | |||
| d38454453b | |||
| 192db06ac5 | |||
| e70ae0add5 | |||
| 162a6820e9 | |||
| fcf346905b | |||
| bf3f3a3afe | |||
| 852b2ce799 | |||
| 5c36af30cd | |||
| 2b14274453 | |||
| df2e8c80bb | |||
| 79088d0902 | |||
| bf72c9c63e | |||
| 33d28c6a45 | |||
| efff88db8e | |||
| 301fc4ee42 | |||
| 9a6ca0700e | |||
| 6596ea977d | |||
| 2c0edb533e | |||
| 245daaff9b | |||
| f272df912b | |||
| 20bf9e1637 | |||
| cbb7bce746 | |||
| 89885a59f4 | |||
| a5c6afe647 | |||
| 1c75c06eaa | |||
| 0b93d99688 | |||
| 34a32b108c | |||
| 3bc2e6c14b | |||
| c168a909db | |||
| ff64fab323 | |||
| 9d45abd693 | |||
| 6db2142cf6 | |||
| 43123df18f | |||
| c3570afeba | |||
| 860b01d5eb | |||
| 4761206a6e | |||
| 9c554a9366 | |||
| c298e258cc | |||
| 6cb5288631 | |||
| 6d434e89ab | |||
| 996472953a | |||
| 8d918b008e | |||
| a66d8a03c6 | |||
| 47fe964c31 | |||
| 2ed1c4a57d | |||
| b168fd54a4 | |||
| da023c60f5 | |||
| 956e82e10c | |||
| cf8d6135ff | |||
| b7fdf97156 | |||
| a79486ad50 | |||
| cb4027418d | |||
| 5e69cd1d47 | |||
| 3f181778a1 | |||
| 96cc69f041 | |||
| b1238eff57 | |||
| c8c2abf3e7 | |||
| 860cdbe332 | |||
| 8b9c862b96 | |||
| ea60371c44 | |||
| 478c0bc2b2 | |||
| 19391808ef | |||
| 9cc6ef4b88 | |||
| 76efe5a207 | |||
| 3bfd8b2328 | |||
| fc14489ab3 | |||
| 3cf2470ffc | |||
| 834841a3d5 | |||
| eaa1950a18 | |||
| aba2da56d7 | |||
| 24314e5ec8 | |||
| 45cb785ba4 | |||
| c4ad44b02a | |||
| df4f8d8fd5 | |||
| 6e62ec4348 | |||
| cf22fe566d | |||
| 31361cff1e | |||
| 235556e703 | |||
| 0a8c6e7477 | |||
| 183a1a8385 | |||
| 0e936b8e20 | |||
| d8a94c8ff6 | |||
| 29c6b174d8 | |||
| 4dcfdeda68 | |||
| 3ea1a6734a | |||
| d1bd036351 | |||
| a6becf67ff | |||
| bb43de3a62 | |||
| d2dc6c51cf | |||
| 8c66ef78c6 | |||
| 29a6f46a0d | |||
| 895be17f7c | |||
| 7c8fd9395b | |||
| 972d817629 | |||
| 31140c7ae2 | |||
| 542ee5f031 | |||
| e28424456f | |||
| e037c02888 | |||
| 9a5d64cc9e | |||
| 1beb1009b3 | |||
| 9cd32a7aa6 | |||
| 473e6df77b | |||
| 088cd33b0a | |||
| 51fd4acd8b | |||
| 691e33a84e | |||
| e60d24d56c | |||
| e1a9d97596 | |||
| f07facd98b | |||
| 8c48216a75 | |||
| c1ff0cedad | |||
| 56650793e8 | |||
| e8568ee6a8 | |||
|
|
1181a2133a | ||
|
|
b9bb1e6653 | ||
|
|
608de34dd3 | ||
|
|
88f1ef5d95 | ||
|
|
b0bf1880e4 | ||
|
|
812879d252 | ||
|
|
303e1830d3 | ||
|
|
61ff1af4c3 | ||
|
|
a3a9f7b351 | ||
|
|
4801833fa9 | ||
|
|
a3fea0bb1f | ||
|
|
b355f4e009 | ||
|
|
b37f181c0f | ||
|
|
1a5a488776 | ||
|
|
2044821e4d | ||
|
|
d9a2e53faf | ||
|
|
d35ff9335f | ||
|
|
715e614f9d | ||
|
|
6101cd80ce | ||
|
|
09d8e7b3da | ||
|
|
0a9f398ef4 | ||
|
|
4cfa097cc8 | ||
|
|
93c549109f | ||
|
|
7f73f3ebb3 | ||
|
|
a2674aae5b | ||
|
|
4771f53d71 | ||
|
|
121d0e36f3 | ||
|
|
88fbc93d97 | ||
|
|
155f2986c7 | ||
|
|
937fce1a35 | ||
|
|
ddf0dafe08 | ||
|
|
71b25144a4 | ||
|
|
4aa14c6a4c | ||
|
|
c9a7c1ae8b | ||
|
|
6f464a9f71 | ||
|
|
23cd5bd835 | ||
|
|
1f75045db4 | ||
|
|
494f83392e | ||
|
|
199bf3160e | ||
|
|
7cb203a08f | ||
|
|
ab3537e35d | ||
|
|
4275f608fe | ||
|
|
3d13f7c880 | ||
|
|
60f4b3370c | ||
|
|
f7c24680cb | ||
|
|
3364aac93d | ||
|
|
fd026efc85 | ||
|
|
9f7523839d | ||
|
|
60b1006447 | ||
|
|
af852d9b59 | ||
|
|
25f51ad53a | ||
|
|
910530748d | ||
|
|
44913a6f2e | ||
|
|
e120ec8e7e | ||
|
|
919b06bba0 | ||
|
|
08c4ef0625 | ||
|
|
e3752e6fe2 | ||
|
|
a5c70db8ad | ||
|
|
8c22eb0877 | ||
|
|
19902d5bed | ||
|
|
e6eda5ce9b | ||
|
|
b33f5263f6 | ||
|
|
fa394bdd87 | ||
|
|
8cb4a26263 | ||
|
|
a9156e57ca | ||
|
|
d9ac803902 | ||
|
|
f9548f1373 | ||
|
|
efa16c64a6 | ||
|
|
82a0ee83b2 | ||
|
|
c697a7edd0 | ||
|
|
da7816bb88 | ||
|
|
d02ecf6a7e | ||
|
|
15ee4e7afa | ||
|
|
1320519e90 | ||
|
|
e50532e2de | ||
|
|
0a08955caa | ||
|
|
115f270ed1 | ||
|
|
77f0460dd3 | ||
|
|
bb8f1c715e | ||
|
|
d9563a25c6 | ||
|
|
046d1a7c9e | ||
|
|
3e362241cc | ||
|
|
8a4b44666f | ||
|
|
52bccd25fc | ||
|
|
0745c4ba9f | ||
|
|
7871544f01 | ||
|
|
daee337e59 | ||
|
|
c11a108f5d | ||
|
|
2fb4740752 | ||
|
|
bd01b179a6 | ||
|
|
66705ae9b1 | ||
|
|
be0d3a9e3c | ||
|
|
e25d91f025 | ||
|
|
eba65a4fbd | ||
|
|
24996a4b35 | ||
|
|
cfc9912a4a | ||
|
|
9f33610f20 | ||
|
|
f41a3d7a3a | ||
|
|
caadb5ea99 | ||
|
|
6b96087b68 | ||
|
|
59a2b173a6 | ||
|
|
6df7986c47 | ||
|
|
dfc62c7e3f | ||
|
|
81ab085e48 | ||
|
|
cd8814189f | ||
|
|
7e1a3be510 | ||
|
|
2cf55dcdde | ||
|
|
571073d7ec | ||
|
|
b0a4c7bea5 | ||
|
|
339d12b7b8 | ||
|
|
92cb614c50 | ||
|
|
738614d724 | ||
|
|
28bcfbb00a | ||
|
|
2ab601fef5 | ||
|
|
e942c63885 | ||
|
|
5cefd8b36d | ||
|
|
cf95e8c046 | ||
|
|
d637a0fa72 | ||
|
|
035171290c | ||
|
|
ae9ec4a7f7 | ||
|
|
6b30eedcc2 | ||
|
|
534450a629 | ||
|
|
b3ec164475 | ||
|
|
49c1447942 | ||
|
|
3ac184f8cd | ||
|
|
4e10f3228e | ||
|
|
20ae92729f | ||
|
|
42a7d49603 | ||
|
|
2e9d257faa | ||
|
|
ecdf4a76f4 | ||
|
|
802d21a896 | ||
|
|
c7ebeaac36 | ||
|
|
3f6d8cb821 | ||
|
|
aa4fe1babd | ||
|
|
3498bc5027 | ||
|
|
1d086c83d4 | ||
|
|
f95759f3e9 | ||
|
|
d9d1efa316 | ||
|
|
a8ee1f0be3 | ||
|
|
1dcbb7cf2f | ||
|
|
2c0982bd27 | ||
|
|
84cdbb94db | ||
|
|
f5d5475ee4 | ||
|
|
7036135d9c | ||
|
|
a6b0d7f28c | ||
|
|
4ce366864e |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,6 +8,11 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.history
|
||||
.github
|
||||
openspec
|
||||
CLAUDE.md
|
||||
docs
|
||||
.DS_Store
|
||||
dist
|
||||
*.local
|
||||
|
||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": []
|
||||
}
|
||||
269
AGENTS.md
Normal file
269
AGENTS.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# AI Agent 企业级行为策略(Ultimate Edition)
|
||||
|
||||
## 核心目标
|
||||
|
||||
AI 应:
|
||||
|
||||
* 像高级工程师一样思考
|
||||
* 保持智能
|
||||
* 保持上下文理解能力
|
||||
* 保持组件联动能力
|
||||
* 同时避免无意义 token 消耗
|
||||
|
||||
目标不是限制 AI。
|
||||
|
||||
目标是:
|
||||
|
||||
* 智能
|
||||
* 克制
|
||||
* 稳定
|
||||
* 高效
|
||||
|
||||
---
|
||||
|
||||
# AI 工作模式
|
||||
|
||||
默认采用:
|
||||
|
||||
Think First
|
||||
Explore Second
|
||||
Modify Last
|
||||
|
||||
即:
|
||||
|
||||
1. 先理解需求
|
||||
2. 再推理可能相关文件
|
||||
3. 再最小化读取
|
||||
4. 最后修改代码
|
||||
|
||||
禁止:
|
||||
|
||||
* 无脑全项目扫描
|
||||
* 不经思考直接 grep
|
||||
* 无限递归读取
|
||||
|
||||
---
|
||||
|
||||
# 智能按需扫描(核心规则)
|
||||
|
||||
允许 AI 自动:
|
||||
|
||||
* 分析当前任务
|
||||
* 分析 import
|
||||
* 分析组件依赖
|
||||
* 分析 store 依赖
|
||||
* 分析 api 依赖
|
||||
* 分析 types 依赖
|
||||
* 分析 utils 依赖
|
||||
|
||||
允许:
|
||||
|
||||
* 自动读取直接依赖文件
|
||||
* 自动修复 import
|
||||
* 自动修复类型引用
|
||||
* 自动分析运行链路
|
||||
|
||||
但必须:
|
||||
|
||||
* 最小化扫描范围
|
||||
* 最小化 token 消耗
|
||||
* 禁止无限递归探索
|
||||
|
||||
---
|
||||
|
||||
# 扫描深度限制
|
||||
|
||||
默认最大依赖深度:
|
||||
|
||||
2 层
|
||||
|
||||
例如:
|
||||
|
||||
index.vue
|
||||
-> ProductCard.vue
|
||||
-> product.ts
|
||||
|
||||
允许读取:
|
||||
|
||||
* ProductCard.vue
|
||||
* product.ts
|
||||
|
||||
禁止继续无限扫描。
|
||||
|
||||
如果任务复杂:
|
||||
|
||||
必须先输出分析计划,
|
||||
等待确认后再扩大扫描范围。
|
||||
|
||||
---
|
||||
|
||||
# AI 自由发挥边界
|
||||
|
||||
允许:
|
||||
|
||||
* 合理重构
|
||||
* 合理组件化
|
||||
* 合理优化结构
|
||||
* 合理优化样式
|
||||
* 合理优化复用
|
||||
* 合理修复低级问题
|
||||
* 合理修复 import
|
||||
* 合理修复类型错误
|
||||
|
||||
禁止:
|
||||
|
||||
* 为了炫技重构项目
|
||||
* 无意义抽象
|
||||
* 过度设计
|
||||
* 无意义拆分
|
||||
* 无意义新增依赖
|
||||
* 自动升级依赖
|
||||
|
||||
---
|
||||
|
||||
# Token 经济策略
|
||||
|
||||
Token 应优先用于:
|
||||
|
||||
* 推理
|
||||
* 架构理解
|
||||
* 业务逻辑
|
||||
* UI 结构优化
|
||||
* 类型安全
|
||||
* 组件联动
|
||||
|
||||
禁止浪费在:
|
||||
|
||||
* 全项目 grep
|
||||
* 重复读取
|
||||
* 重复输出
|
||||
* 重复解释
|
||||
* 输出完整项目
|
||||
* 输出未修改代码
|
||||
|
||||
---
|
||||
|
||||
# 页面生成规则(Figma / uni-app)
|
||||
|
||||
允许:
|
||||
|
||||
* 自动组件化
|
||||
* 自动布局优化
|
||||
* 自动结构优化
|
||||
* 自动提取公共组件
|
||||
|
||||
优先:
|
||||
|
||||
* flex 布局
|
||||
* 可维护性
|
||||
* uni-app 最佳实践
|
||||
* 低嵌套结构
|
||||
* 高复用结构
|
||||
|
||||
禁止:
|
||||
|
||||
* div 套 div
|
||||
* 全 absolute 页面
|
||||
* 垃圾 HTML
|
||||
* 无意义嵌套
|
||||
* 内联 style 泛滥
|
||||
|
||||
---
|
||||
|
||||
# uni-app 规则
|
||||
|
||||
必须:
|
||||
|
||||
* 使用 view/text/image
|
||||
* px 转 rpx
|
||||
* 使用 script setup
|
||||
* scoped scss
|
||||
* 兼容:
|
||||
|
||||
* H5
|
||||
* 微信小程序
|
||||
* App
|
||||
|
||||
---
|
||||
|
||||
# 大任务策略
|
||||
|
||||
复杂任务:
|
||||
|
||||
必须:
|
||||
|
||||
1. 先分析
|
||||
2. 先规划
|
||||
3. 先输出方案
|
||||
4. 等待确认
|
||||
|
||||
再:
|
||||
|
||||
5. 编码
|
||||
|
||||
禁止直接进入大规模代码生成。
|
||||
|
||||
---
|
||||
|
||||
# 修改策略
|
||||
|
||||
优先:
|
||||
|
||||
* diff 修改
|
||||
* 小范围 patch
|
||||
* 保持现有架构
|
||||
* 保持现有组件体系
|
||||
* 保持现有 API 结构
|
||||
|
||||
允许:
|
||||
|
||||
* 小范围智能优化
|
||||
|
||||
禁止:
|
||||
|
||||
* 全项目重构
|
||||
* 无关文件修改
|
||||
|
||||
---
|
||||
|
||||
# 高级工程师行为模式
|
||||
|
||||
AI 应像高级工程师:
|
||||
|
||||
* 先思考
|
||||
* 再探索
|
||||
* 再修改
|
||||
|
||||
而不是:
|
||||
|
||||
* 无脑扫描器
|
||||
* Token 消耗机器
|
||||
* 低级代码生成器
|
||||
|
||||
AI 应主动:
|
||||
|
||||
* 控制扫描范围
|
||||
* 控制输出长度
|
||||
* 控制修改范围
|
||||
* 控制复杂度
|
||||
|
||||
同时保持:
|
||||
|
||||
* 智能
|
||||
* 联动能力
|
||||
* 架构理解能力
|
||||
|
||||
---
|
||||
|
||||
# 默认输出规则
|
||||
|
||||
默认:
|
||||
|
||||
* 仅输出修改部分
|
||||
* 不重复未修改代码
|
||||
* 少解释
|
||||
* 优先 patch
|
||||
* 优先 diff
|
||||
|
||||
除非用户明确要求:
|
||||
否则不要输出完整项目。
|
||||
116
doc.md
Normal file
116
doc.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# 微信小程序多人协作分支管理规范
|
||||
|
||||
## 一、分支结构
|
||||
|
||||
```
|
||||
main (主分支/生产环境)
|
||||
└── test (测试分支)
|
||||
└── feature/xxx (个人开发分支)
|
||||
```
|
||||
|
||||
| 分支 | 用途 | 稳定性 |
|
||||
|------|------|--------|
|
||||
| main | 生产环境代码 | 最高,仅接受测试通过的代码合并 |
|
||||
| test | 测试环境,用于体验版发布 | 中,需验证后合并到 main |
|
||||
| feature/xxx | 个人开发分支 | 低,按需命名,如 `feature/user-center` |
|
||||
|
||||
---
|
||||
|
||||
## 二、开发流程
|
||||
|
||||
### 1. 开始开发
|
||||
|
||||
```bash
|
||||
# 确保本地 main 最新
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
# 从 main 创建自己的开发分支
|
||||
git checkout -b feature/your-name-work
|
||||
```
|
||||
|
||||
### 2. 开发阶段
|
||||
|
||||
- 在个人分支上开发功能
|
||||
- 频繁提交,保持原子性提交
|
||||
- 定期 `git pull origin main` 同步主线变更,避免合并冲突累积
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: 完成xxx功能"
|
||||
```
|
||||
|
||||
### 3. 合并到 test 分支
|
||||
|
||||
```bash
|
||||
# 切换到 test
|
||||
git checkout test
|
||||
git pull origin test
|
||||
|
||||
# 合并个人分支
|
||||
git merge feature/your-name-work
|
||||
|
||||
# 推送 test 分支
|
||||
git push origin test
|
||||
```
|
||||
|
||||
### 4. 打包上传体验版
|
||||
|
||||
```bash
|
||||
# 执行打包
|
||||
npm run build
|
||||
```
|
||||
|
||||
打包完成后:
|
||||
|
||||
1. 打开 **微信开发者工具**
|
||||
2. 导入项目,选择 `dist/build/mp-weixin` 目录
|
||||
3. 在开发者工具中点击 **上传**
|
||||
4. 登录 [微信公众平台](https://mp.weixin.qq.com)
|
||||
5. 进入 **管理->版本管理**
|
||||
6. 找到刚上传的版本,点击 **选为体验版**
|
||||
|
||||
---
|
||||
|
||||
## 三、合并到 main 分支
|
||||
|
||||
当 test 分支验证通过后,将其合并到 main:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
git merge origin/test
|
||||
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、冲突处理
|
||||
|
||||
合并时如有冲突,在个人分支解决后再合并:
|
||||
|
||||
```bash
|
||||
git checkout feature/your-name-work
|
||||
git merge main
|
||||
# 解决冲突后
|
||||
git add .
|
||||
git commit -m "merge: 解决与main的冲突"
|
||||
git push origin feature/your-name-work
|
||||
|
||||
# 重新合并到 test
|
||||
git checkout test
|
||||
git merge feature/your-name-work
|
||||
git push origin test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
1. **禁止直接向 main 和 test 分支提交代码**,必须通过合并
|
||||
2. **每次合并前先拉取最新代码**,避免覆盖他人改动
|
||||
3. **体验版发布前确认代码已提交**,避免遗漏
|
||||
4. **开发分支命名建议**:`feature/姓名-功能名`,如 `feature/zhangsan-login`
|
||||
5. **删除已合并的开发分支**:`git branch -d feature/your-name-work`
|
||||
10532
package-lock.json
generated
Normal file
10532
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -23,7 +23,9 @@
|
||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-mp-xhs": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-ui": "^1.5.11",
|
||||
"pinia": "2.0.36",
|
||||
"pinia-plugin-persistedstate": "3.2.1",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
|
||||
548
src/App.vue
548
src/App.vue
@@ -1,231 +1,369 @@
|
||||
<script setup>
|
||||
import { watch } from "vue";
|
||||
import { onShow, onHide } from "@dcloudio/uni-app";
|
||||
import websocket from "@/websocket";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const { updateUser } = store;
|
||||
import {
|
||||
watch
|
||||
} from "vue";
|
||||
import {
|
||||
onShow,
|
||||
onHide
|
||||
} from "@dcloudio/uni-app";
|
||||
import websocket from "@/websocket";
|
||||
import {
|
||||
getDeviceBatteryAPI
|
||||
} from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import {
|
||||
storeToRefs
|
||||
} from "pinia";
|
||||
import audioManager from "./audioManager";
|
||||
const store = useStore();
|
||||
const {
|
||||
user
|
||||
} = storeToRefs(store);
|
||||
const {
|
||||
updateUser,
|
||||
updateOnline,
|
||||
clearSessionState
|
||||
} = store;
|
||||
|
||||
watch(
|
||||
() => user.value.id,
|
||||
(newVal) => {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
if (newVal && token) {
|
||||
websocket.createWebSocket(token, (content) => {
|
||||
uni.$emit("socket-inbox", content);
|
||||
});
|
||||
}
|
||||
if (!newVal) {
|
||||
websocket.closeWebSocket();
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: false, // 如果 user 是一个对象或数组,建议开启
|
||||
immediate: false, // 若想在初始化时立即执行一次回调,可开启。
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => user.value.id,
|
||||
(newVal) => {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
if (newVal && token) {
|
||||
websocket.createWebSocket(token, onShootWsMsg);
|
||||
}
|
||||
if (!newVal) {
|
||||
websocket.closeWebSocket();
|
||||
}
|
||||
}, {
|
||||
deep: false, // 如果 user 是一个对象或数组,建议开启
|
||||
immediate: false, // 若想在初始化时立即执行一次回调,可开启。
|
||||
}
|
||||
);
|
||||
|
||||
onShow(() => {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
if (user.value.id && token) {
|
||||
console.log("回到前台,重新连接 websocket");
|
||||
websocket.createWebSocket(token, (content) => {
|
||||
uni.$emit("socket-inbox", content);
|
||||
});
|
||||
}
|
||||
});
|
||||
function emitUpdateUser(value) {
|
||||
updateUser(value);
|
||||
}
|
||||
|
||||
onHide(() => {
|
||||
websocket.closeWebSocket();
|
||||
});
|
||||
function onSessionKickedOut() {
|
||||
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||
uni.removeStorageSync(`${env}_token`);
|
||||
clearSessionState();
|
||||
uni.showModal({
|
||||
title: "提示",
|
||||
content: "账号已在其他设备登录",
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
|
||||
async function emitUpdateOnline() {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
updateOnline(data.online);
|
||||
}
|
||||
|
||||
function onDeviceShoot() {
|
||||
// audioManager.play("射箭声音")
|
||||
}
|
||||
|
||||
function onShootWsMsg(content) {
|
||||
if(content.type === 'shoot-trigger'){
|
||||
onDeviceShoot()
|
||||
}
|
||||
uni.$emit("socket-inbox", content);
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
uni.$on("update-user", emitUpdateUser);
|
||||
uni.$on("update-online", emitUpdateOnline);
|
||||
uni.$on("session-kicked-out", onSessionKickedOut);
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
if (user.value.id && token) {
|
||||
console.log("回到前台,重新连接 websocket");
|
||||
websocket.createWebSocket(token, onShootWsMsg);
|
||||
}
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
uni.$off("update-user", emitUpdateUser);
|
||||
uni.$off("update-online", emitUpdateOnline);
|
||||
uni.$off("session-kicked-out", onSessionKickedOut);
|
||||
websocket.closeWebSocket();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
page {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
background-color: #000;
|
||||
}
|
||||
page {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
line-height: 1;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
line-height: 1;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
view::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
view::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
button::after {
|
||||
border: none;
|
||||
}
|
||||
button::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.guide-tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.guide-tips > text:first-child {
|
||||
color: #fed847;
|
||||
}
|
||||
.guide-tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.guide-tips>text:first-child {
|
||||
color: #fed847;
|
||||
}
|
||||
|
||||
.fade-in-out {
|
||||
animation: fadeInOut 1.2s ease forwards;
|
||||
}
|
||||
.guide-tips>text:nth-child(2) {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes fadeInOut {
|
||||
0% {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.fade-out {
|
||||
animation: fadeOut 0.3s ease forwards;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.scale-in {
|
||||
animation: scaleIn 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleOut {
|
||||
from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.fade-in-out {
|
||||
animation: fadeInOut 1.2s ease forwards;
|
||||
}
|
||||
|
||||
.scale-out {
|
||||
animation: scaleOut 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
@keyframes fadeOut {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
to {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pumpIn {
|
||||
from {
|
||||
transform: scale(2);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.fade-out {
|
||||
animation: fadeOut 0.3s ease forwards;
|
||||
}
|
||||
|
||||
.pump-in {
|
||||
animation: pumpIn 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.share-canvas {
|
||||
width: 300px;
|
||||
height: 534px;
|
||||
position: absolute;
|
||||
top: -1000px;
|
||||
left: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.scale-in {
|
||||
animation: scaleIn 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.modal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
padding-top: 7px;
|
||||
position: relative;
|
||||
}
|
||||
.half-time-tip {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.half-time-tip > text:last-child {
|
||||
margin-top: 20px;
|
||||
color: #fff9;
|
||||
}
|
||||
.see-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.see-more > text {
|
||||
color: #39a8ff;
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.see-more > image {
|
||||
width: 15px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
@keyframes scaleOut {
|
||||
from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scale-out {
|
||||
animation: scaleOut 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pumpIn {
|
||||
from {
|
||||
transform: scale(2);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.pump-in {
|
||||
animation: pumpIn 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.share-canvas {
|
||||
width: 300px;
|
||||
height: 530px;
|
||||
position: absolute;
|
||||
top: -1000px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-nickname {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member-nickname__text,
|
||||
.member-nickname__shine {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-nickname--vip .member-nickname__text {
|
||||
color: #E7BA80;
|
||||
}
|
||||
|
||||
.member-nickname--svip .member-nickname__text {
|
||||
background: linear-gradient(90deg, #ffb86c, #ff4fd8, #7c5cff, #35d6ff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.member-nickname__shine {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
transparent 0%,
|
||||
transparent 38%,
|
||||
rgba(255, 255, 255, 0.15) 45%,
|
||||
rgba(255, 255, 255, 1) 50%,
|
||||
rgba(255, 255, 255, 0.15) 55%,
|
||||
transparent 62%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
background-position: 120% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
animation: memberNicknameShine 3.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes memberNicknameShine {
|
||||
0%,
|
||||
50% {
|
||||
background-position: 120% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 15px;
|
||||
padding-top: 7px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.half-time-tip {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.half-time-tip>text:last-child {
|
||||
margin-top: 20px;
|
||||
color: #fff9;
|
||||
}
|
||||
|
||||
.see-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.see-more>text {
|
||||
color: #39a8ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.see-more>image {
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "DINCondensed";
|
||||
src: url("https://static.shelingxingqiu.com/font/DIN-Condensed-Bold-2.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
</style>
|
||||
|
||||
788
src/apis.js
788
src/apis.js
@@ -1,500 +1,564 @@
|
||||
let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版
|
||||
|
||||
try {
|
||||
const accountInfo = uni.getAccountInfoSync();
|
||||
const envVersion = accountInfo.miniProgram.envVersion;
|
||||
const accountInfo = uni.getAccountInfoSync();
|
||||
const envVersion = accountInfo.miniProgram.envVersion;
|
||||
|
||||
switch (envVersion) {
|
||||
case "develop": // 开发版
|
||||
// BASE_URL = "http://192.168.1.242:8000/api/shoot";
|
||||
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
|
||||
break;
|
||||
case "trial": // 体验版
|
||||
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
|
||||
break;
|
||||
case "release": // 正式版
|
||||
BASE_URL = "https://api.shelingxingqiu.com/api/shoot";
|
||||
break;
|
||||
default:
|
||||
// 保持默认值
|
||||
break;
|
||||
}
|
||||
switch (envVersion) {
|
||||
case "develop": // 开发版
|
||||
// BASE_URL = "http://192.168.1.30:8000/api/shoot";
|
||||
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
|
||||
break;
|
||||
case "trial": // 体验版
|
||||
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
|
||||
break;
|
||||
case "release": // 正式版
|
||||
BASE_URL = "https://api.shelingxingqiu.com/api/shoot";
|
||||
break;
|
||||
default:
|
||||
// 保持默认值
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("获取环境信息失败,使用默认正式环境", e);
|
||||
console.error("获取环境信息失败,使用默认正式环境", e);
|
||||
}
|
||||
|
||||
function request(method, url, data = {}) {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
const header = {};
|
||||
if (token) header.Authorization = `Bearer ${token || ""}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${BASE_URL}${url}`,
|
||||
method,
|
||||
header,
|
||||
data,
|
||||
timeout: 10000,
|
||||
success: (res) => {
|
||||
if (res.data) {
|
||||
const { code, data, message } = res.data;
|
||||
if (code === 0) resolve(data);
|
||||
else if (message) {
|
||||
if (message.indexOf("登录身份已失效") !== -1) {
|
||||
uni.removeStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
}
|
||||
if (message === "ROOM_FULL") {
|
||||
resolve({ full: true });
|
||||
return;
|
||||
}
|
||||
if (message === "ERROR_ROOM_GAME_START") {
|
||||
resolve({ started: true });
|
||||
return;
|
||||
}
|
||||
if (url.indexOf("/user/room") !== -1 && method === "GET") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
if (message === "ERROR_BATTLE_GAMING") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
if (message === "BIND_DEVICE") {
|
||||
resolve({ binded: true });
|
||||
return;
|
||||
}
|
||||
if (message === "ERROR_ORDER_UNPAY") {
|
||||
uni.showToast({
|
||||
title: "当前有未支付订单",
|
||||
icon: "none",
|
||||
});
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
if (message === "ROOM_EMPTY") {
|
||||
return uni.showToast({
|
||||
title: "房间已过期",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
uni.showToast({
|
||||
title: message,
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
reject("");
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
handleRequestError(err, url);
|
||||
reject(err);
|
||||
},
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
const header = {};
|
||||
if (token) header.Authorization = `Bearer ${token || ""}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${BASE_URL}${url}`,
|
||||
method,
|
||||
header,
|
||||
data,
|
||||
timeout: 10000,
|
||||
success: (res) => {
|
||||
if (res.data) {
|
||||
const {code, data, message} = res.data;
|
||||
if (code === 0) resolve(data);
|
||||
else if (message) {
|
||||
if (message.indexOf("登录身份已失效") !== -1) {
|
||||
console.log('1111111111111111111,token失效')
|
||||
uni.removeStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
uni.$emit("update-user");
|
||||
reject({ type: "AUTH_INVALID", message });
|
||||
return;
|
||||
}
|
||||
if (message === "ROOM_FULL") {
|
||||
resolve({full: true});
|
||||
return;
|
||||
}
|
||||
if (message === "ERROR_ROOM_GAME_START") {
|
||||
resolve({started: true});
|
||||
return;
|
||||
}
|
||||
if (url.indexOf("/user/room") !== -1 && method === "GET") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
if (message === "ERROR_BATTLE_GAMING") {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
if (message === "BIND_DEVICE") {
|
||||
resolve({binded: true});
|
||||
return;
|
||||
}
|
||||
if (message === "ERROR_ORDER_UNPAY") {
|
||||
uni.showToast({
|
||||
title: "当前有未支付订单",
|
||||
icon: "none",
|
||||
});
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
if (message === "ROOM_EMPTY") {
|
||||
return uni.showToast({
|
||||
title: "房间已过期",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
uni.showToast({
|
||||
title: message,
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
reject("");
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
handleRequestError(err, url);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 统一的错误处理函数
|
||||
function handleRequestError(err, url) {
|
||||
console.log("请求失败:", { err, url });
|
||||
console.log("请求失败:", {err, url});
|
||||
|
||||
// 根据错误类型显示不同提示
|
||||
if (err.errMsg) {
|
||||
if (err.errMsg.includes("timeout")) {
|
||||
showCustomToast("请求超时,请稍后重试", "timeout");
|
||||
} else if (err.errMsg.includes("fail")) {
|
||||
// 检查网络状态
|
||||
uni.getNetworkType({
|
||||
success: (res) => {
|
||||
if (res.networkType === "none") {
|
||||
showCustomToast("网络连接已断开,请检查网络设置", "network");
|
||||
} else {
|
||||
showCustomToast("服务器连接失败,请稍后重试", "server");
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
showCustomToast("网络异常,请检查网络连接", "unknown");
|
||||
},
|
||||
});
|
||||
// 根据错误类型显示不同提示
|
||||
if (err.errMsg) {
|
||||
if (err.errMsg.includes("timeout")) {
|
||||
showCustomToast("请求超时,请稍后重试", "timeout");
|
||||
} else if (err.errMsg.includes("fail")) {
|
||||
// 检查网络状态
|
||||
uni.getNetworkType({
|
||||
success: (res) => {
|
||||
if (res.networkType === "none") {
|
||||
showCustomToast("网络连接已断开,请检查网络设置", "network");
|
||||
} else {
|
||||
showCustomToast("服务器连接失败,请稍后重试", "server");
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
showCustomToast("网络异常,请检查网络连接", "unknown");
|
||||
},
|
||||
});
|
||||
} else {
|
||||
showCustomToast("请求失败,请稍后重试", "general");
|
||||
}
|
||||
} else {
|
||||
showCustomToast("请求失败,请稍后重试", "general");
|
||||
showCustomToast("网络异常,请稍后重试", "unknown");
|
||||
}
|
||||
} else {
|
||||
showCustomToast("网络异常,请稍后重试", "unknown");
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义提示函数
|
||||
function showCustomToast(message, type) {
|
||||
const config = {
|
||||
title: message,
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
};
|
||||
const config = {
|
||||
title: message,
|
||||
icon: "none",
|
||||
duration: 3000,
|
||||
};
|
||||
|
||||
// 根据错误类型可以添加不同的处理逻辑
|
||||
switch (type) {
|
||||
case "timeout":
|
||||
config.duration = 4000; // 超时提示显示更久
|
||||
break;
|
||||
case "network":
|
||||
config.duration = 5000; // 网络问题提示显示更久
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// 根据错误类型可以添加不同的处理逻辑
|
||||
switch (type) {
|
||||
case "timeout":
|
||||
config.duration = 4000; // 超时提示显示更久
|
||||
break;
|
||||
case "network":
|
||||
config.duration = 5000; // 网络问题提示显示更久
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
uni.showToast(config);
|
||||
uni.showToast(config);
|
||||
}
|
||||
|
||||
// 获取全局配置
|
||||
export const getAppConfig = () => {
|
||||
return request("GET", "/index/appConfig");
|
||||
return request("GET", "/index/appConfig");
|
||||
};
|
||||
|
||||
export const getHomeData = (seasonId) => {
|
||||
return request("GET", `/user/myHome?seasonId=${seasonId}`);
|
||||
return request("GET", `/user/myHome?seasonId=${seasonId}`);
|
||||
};
|
||||
|
||||
export const getProvinceData = () => {
|
||||
return request("GET", "/index/provinces/list");
|
||||
return request("GET", "/index/provinces/list");
|
||||
};
|
||||
|
||||
export const loginAPI = async (nickName, avatarData, code) => {
|
||||
const result = await request("POST", "/index/code", {
|
||||
appName: "shoot",
|
||||
appId: "wxa8f5989dcd45cc23",
|
||||
nickName,
|
||||
avatarData,
|
||||
code,
|
||||
});
|
||||
uni.setStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`,
|
||||
result.token
|
||||
);
|
||||
return result;
|
||||
export const loginAPI = async (phone, nickName, avatarData, code) => {
|
||||
const result = await request("POST", "/index/code", {
|
||||
appName: "shoot",
|
||||
appId: "wxa8f5989dcd45cc23",
|
||||
nickName,
|
||||
avatarData,
|
||||
code,
|
||||
phone,
|
||||
});
|
||||
uni.setStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`,
|
||||
result.token
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const silentLoginAPI = async (code) => {
|
||||
const result = await request("POST", "/index/code", {
|
||||
appName: "shoot",
|
||||
appId: "wxa8f5989dcd45cc23",
|
||||
code,
|
||||
});
|
||||
uni.setStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`,
|
||||
result.token
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const checkUserBindAPI = async (code) => {
|
||||
return request("POST", "/index/checkBind", {
|
||||
appName: "shoot",
|
||||
appId: "wxa8f5989dcd45cc23",
|
||||
code,
|
||||
});
|
||||
};
|
||||
|
||||
export const bindDeviceAPI = (device) => {
|
||||
return request("POST", "/user/device/bindDevice", {
|
||||
device,
|
||||
});
|
||||
return request("POST", "/user/device/bindDevice", {
|
||||
device,
|
||||
});
|
||||
};
|
||||
|
||||
export const bindDeviceAPIV2 = (token) => {
|
||||
return request("POST", "/user/device/bindDevice/v2", {
|
||||
token: token,
|
||||
});
|
||||
};
|
||||
|
||||
export const unbindDeviceAPI = (deviceId) => {
|
||||
return request("POST", "/user/device/unbindDevice", {
|
||||
deviceId,
|
||||
});
|
||||
return request("POST", "/user/device/unbindDevice", {
|
||||
deviceId,
|
||||
});
|
||||
};
|
||||
|
||||
export const getMyDevicesAPI = () => {
|
||||
// "/user/device/getBinding?deviceId=9ZF9oVXs"
|
||||
return request("GET", "/user/device/getBindings");
|
||||
// "/user/device/getBinding?deviceId=9ZF9oVXs"
|
||||
return request("GET", "/user/device/getBindings");
|
||||
};
|
||||
|
||||
export const createPractiseAPI = (arrows) => {
|
||||
return request("POST", "/user/practice/create", {
|
||||
arrows,
|
||||
});
|
||||
export const createPractiseAPI = (arrows, time, target) => {
|
||||
return request("POST", "/user/practice/create", {
|
||||
shootNumber: arrows,
|
||||
shootTime: time,
|
||||
targetType: target * 20,
|
||||
});
|
||||
};
|
||||
|
||||
export const startPractiseAPI = () => {
|
||||
return request("POST", "/user/practice/begin");
|
||||
};
|
||||
|
||||
export const endPractiseAPI = () => {
|
||||
return request("POST", "/user/practice/stop");
|
||||
};
|
||||
|
||||
export const getPractiseAPI = async (id) => {
|
||||
const result = await request("GET", `/user/practice/get?id=${id}`);
|
||||
const data = { ...(result.UserPracticeRound || {}) };
|
||||
if (data.arrows) data.arrows = JSON.parse(data.arrows);
|
||||
return data;
|
||||
return request("GET", `/user/practice/get?id=${id}`);
|
||||
};
|
||||
|
||||
export const createRoomAPI = (gameType, teamSize) => {
|
||||
return request("POST", "/user/createroom", {
|
||||
gameType,
|
||||
teamSize,
|
||||
});
|
||||
export const createRoomAPI = (gameType, teamSize, targetType) => {
|
||||
return request("POST", "/user/createroom", {
|
||||
gameType,
|
||||
teamSize,
|
||||
targetType,
|
||||
});
|
||||
};
|
||||
|
||||
export const getRoomAPI = (number) => {
|
||||
return request("GET", `/user/room?number=${number}`);
|
||||
return request("GET", `/user/room?number=${number}`);
|
||||
};
|
||||
|
||||
export const joinRoomAPI = (number) => {
|
||||
return request("POST", `/user/room/join`, { number });
|
||||
return request("POST", `/user/room/join`, {number});
|
||||
};
|
||||
|
||||
export const destroyRoomAPI = (roomNumber) => {
|
||||
return request("POST", "/user/room/destroyRoom", {
|
||||
roomNumber,
|
||||
});
|
||||
return request("POST", "/user/room/destroyRoom", {
|
||||
roomNumber,
|
||||
});
|
||||
};
|
||||
|
||||
export const exitRoomAPI = (number) => {
|
||||
return request("POST", "/user/room/exitRoom", {
|
||||
number,
|
||||
});
|
||||
export const exitRoomAPI = (number, userId) => {
|
||||
return request("POST", "/user/room/exitRoom", {
|
||||
number,
|
||||
userId,
|
||||
});
|
||||
};
|
||||
|
||||
export const startRoomAPI = (number) => {
|
||||
return request("POST", "/user/room/start", { number });
|
||||
return request("POST", "/user/room/start", {number});
|
||||
};
|
||||
|
||||
export const getPractiseResultListAPI = async (page = 1, page_size = 15) => {
|
||||
const reuslt = await request(
|
||||
"GET",
|
||||
`/user/practice/list?page=${page}&page_size=${page_size}`
|
||||
);
|
||||
return reuslt.list;
|
||||
const reuslt = await request(
|
||||
"GET",
|
||||
`/user/practice/list?page=${page}&page_size=${page_size}`
|
||||
);
|
||||
return reuslt.list;
|
||||
};
|
||||
|
||||
export const matchGameAPI = (match, gameType, teamSize) => {
|
||||
return request("POST", "/user/game/match", {
|
||||
match,
|
||||
gameType,
|
||||
teamSize,
|
||||
});
|
||||
return request("POST", "/user/game/match", {
|
||||
match,
|
||||
gameType,
|
||||
teamSize,
|
||||
readyTime: 15,
|
||||
targetType: 20,
|
||||
});
|
||||
};
|
||||
|
||||
export const readyGameAPI = (battleId) => {
|
||||
return request("POST", "/user/game/prepare", {
|
||||
battleId,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGameAPI = async (battleId) => {
|
||||
const result = await request("POST", "/user/battle/detail", {
|
||||
id: battleId,
|
||||
});
|
||||
if (!result.battleStats) return {};
|
||||
const {
|
||||
battleStats = {},
|
||||
playerStats = {},
|
||||
goldenRoundRecords = [],
|
||||
} = result;
|
||||
const data = {
|
||||
id: battleId,
|
||||
mode: battleStats.mode, // 1.几V几 2.大乱斗
|
||||
gameMode: battleStats.gameMode, // 1.约战 2.排位
|
||||
teamSize: battleStats.teamSize,
|
||||
};
|
||||
if (battleStats && battleStats.mode === 1) {
|
||||
data.winner = battleStats.winner;
|
||||
data.roundsData = {};
|
||||
data.redPlayers = {};
|
||||
data.bluePlayers = {};
|
||||
data.mvps = [];
|
||||
data.goldenRounds =
|
||||
goldenRoundRecords && goldenRoundRecords.length ? goldenRoundRecords : [];
|
||||
playerStats.forEach((item) => {
|
||||
const { playerBattleStats = {}, roundRecords = [] } = item;
|
||||
if (playerBattleStats.team === 0) {
|
||||
data.redPlayers[playerBattleStats.playerId] = playerBattleStats;
|
||||
}
|
||||
if (playerBattleStats.team === 1) {
|
||||
data.bluePlayers[playerBattleStats.playerId] = playerBattleStats;
|
||||
}
|
||||
if (playerBattleStats.mvp) {
|
||||
data.mvps.push(playerBattleStats);
|
||||
}
|
||||
roundRecords.forEach((round) => {
|
||||
data.roundsData[round.roundNumber] = {
|
||||
...data.roundsData[round.roundNumber],
|
||||
[round.playerId]: round.arrowHistory,
|
||||
};
|
||||
});
|
||||
return request("POST", "/user/game/prepare", {
|
||||
battleId,
|
||||
});
|
||||
const totalRounds = Object.keys(data.roundsData).length;
|
||||
(goldenRoundRecords || []).forEach((item, index) => {
|
||||
item.arrowHistory.forEach((arrow) => {
|
||||
if (!data.roundsData[totalRounds + index + 1]) {
|
||||
data.roundsData[totalRounds + index + 1] = {};
|
||||
}
|
||||
if (!data.roundsData[totalRounds + index + 1][arrow.playerId]) {
|
||||
data.roundsData[totalRounds + index + 1][arrow.playerId] = [];
|
||||
}
|
||||
data.roundsData[totalRounds + index + 1][arrow.playerId].push(arrow);
|
||||
});
|
||||
});
|
||||
|
||||
data.mvps.sort((a, b) => b.totalRings - a.totalRings);
|
||||
}
|
||||
if (battleStats && battleStats.mode === 2) {
|
||||
data.players = [];
|
||||
playerStats.forEach((item) => {
|
||||
data.players.push({
|
||||
...item.playerBattleStats,
|
||||
arrowHistory: item.roundRecords[0].arrowHistory,
|
||||
});
|
||||
});
|
||||
data.players = data.players.sort((a, b) => b.totalScore - a.totalScore);
|
||||
}
|
||||
// console.log("game result:", result);
|
||||
// console.log("format data:", data);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const simulShootAPI = (device_id, x, y) => {
|
||||
const data = {
|
||||
device_id,
|
||||
};
|
||||
if (x !== undefined && y !== undefined) {
|
||||
data.x = x;
|
||||
data.y = y;
|
||||
}
|
||||
return request("POST", "/index/arrow", data);
|
||||
const data = {
|
||||
device_id,
|
||||
};
|
||||
if (x !== undefined && y !== undefined) {
|
||||
data.x = x;
|
||||
data.y = y;
|
||||
}
|
||||
return request("POST", "/index/arrow", data);
|
||||
};
|
||||
|
||||
export const getBattleListAPI = async (page, battleType) => {
|
||||
const data = [];
|
||||
const result = await request("POST", "/user/battle/details/list", {
|
||||
page,
|
||||
battleType,
|
||||
modeType: 0,
|
||||
});
|
||||
(result.Battles || []).forEach((item) => {
|
||||
let name = "";
|
||||
if (item.battleStats.mode === 1) {
|
||||
name = `${item.playerStats.length / 2}V${item.playerStats.length / 2}`;
|
||||
}
|
||||
if (item.battleStats.mode === 2) {
|
||||
name = `${item.playerStats.length}人大乱斗`;
|
||||
}
|
||||
data.push({
|
||||
name,
|
||||
battleId: item.battleStats.battleId,
|
||||
mode: item.battleStats.mode,
|
||||
createdAt: item.battleStats.createdAt,
|
||||
gameEndAt: item.battleStats.gameEndAt,
|
||||
winner: item.battleStats.winner,
|
||||
players: item.playerStats
|
||||
.map((p) => p.playerBattleStats)
|
||||
.sort((a, b) => b.totalScore - a.totalScore),
|
||||
redPlayers: item.playerStats
|
||||
.filter((p) => p.playerBattleStats.team === 0)
|
||||
.map((p) => p.playerBattleStats),
|
||||
bluePlayers: item.playerStats
|
||||
.filter((p) => p.playerBattleStats.team === 1)
|
||||
.map((p) => p.playerBattleStats),
|
||||
const result = await request("POST", "/user/battle/details/list", {
|
||||
page,
|
||||
pageSize: 10,
|
||||
battleType,
|
||||
});
|
||||
});
|
||||
return data;
|
||||
return result.list;
|
||||
};
|
||||
|
||||
export const getRankListAPI = () => {
|
||||
return request("GET", "/index/ranklist");
|
||||
return request("GET", "/index/ranklist");
|
||||
};
|
||||
|
||||
export const createOrderAPI = (vipId) => {
|
||||
return request("POST", "/user/order/create", {
|
||||
vipId,
|
||||
quanity: 1,
|
||||
tradeType: "mini",
|
||||
payType: "wxpay",
|
||||
});
|
||||
return request("POST", "/user/order/create", {
|
||||
vipId,
|
||||
quanity: 1,
|
||||
tradeType: "mini",
|
||||
payType: "wxpay",
|
||||
returnUrl: "",
|
||||
remark: "",
|
||||
mockTest: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const payOrderAPI = (id) => {
|
||||
return request("POST", "/user/order/pay", {
|
||||
id,
|
||||
tradeType: "mini",
|
||||
payType: "wxpay",
|
||||
});
|
||||
return request("POST", "/user/order/pay", {
|
||||
id,
|
||||
tradeType: "mini",
|
||||
payType: "wxpay",
|
||||
});
|
||||
};
|
||||
|
||||
export const getOrderListAPI = async (page) => {
|
||||
const reuslt = await request("GET", `/user/order/list?page=${page}`);
|
||||
return reuslt.items || [];
|
||||
const reuslt = await request("GET", `/user/order/list?page=${page}`);
|
||||
return reuslt.items || [];
|
||||
};
|
||||
|
||||
export const cancelOrderListAPI = async (id) => {
|
||||
return request("POST", "/user/order/cancelOrder", { id });
|
||||
return request("POST", "/user/order/cancelOrder", {id});
|
||||
};
|
||||
|
||||
export const isGamingAPI = async () => {
|
||||
const result = await request("GET", "/user/isGaming");
|
||||
return result.gaming || false;
|
||||
};
|
||||
|
||||
export const getCurrentGameAPI = async () => {
|
||||
uni.$emit("update-header-loading", true);
|
||||
const result = await request("GET", "/user/join/battle");
|
||||
return result.currentGame || {};
|
||||
export const getUserGameState = () => {
|
||||
return request("GET", "/user/state");
|
||||
};
|
||||
|
||||
export const getPointBookConfigAPI = async () => {
|
||||
return request("GET", "/user/score/sheet/option");
|
||||
return request("GET", "/user/score/sheet/option");
|
||||
};
|
||||
|
||||
export const savePointBookAPI = async (
|
||||
bowType,
|
||||
distance,
|
||||
targetType,
|
||||
groups,
|
||||
arrows,
|
||||
data = []
|
||||
) => {
|
||||
return request("POST", "/user/score/sheet/report", {
|
||||
bowType,
|
||||
distance,
|
||||
targetType,
|
||||
groups,
|
||||
arrows,
|
||||
group_data: data.map((item) =>
|
||||
item.map((i) => ({
|
||||
...i,
|
||||
ring: i.ring === "M" ? -1 : i.ring === "X" ? 0 : Number(i.ring),
|
||||
}))
|
||||
),
|
||||
});
|
||||
data = []
|
||||
) => {
|
||||
return request("POST", "/user/score/sheet/report", {
|
||||
bowType,
|
||||
distance,
|
||||
targetType,
|
||||
groups,
|
||||
arrows,
|
||||
group_data: data.map((item) =>
|
||||
item.map((i) => ({
|
||||
...i,
|
||||
ring: i.ring === "M" ? -1 : i.ring === "X" ? 0 : Number(i.ring),
|
||||
}))
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const getPointBookListAPI = async (
|
||||
page = 1,
|
||||
bowType,
|
||||
distance,
|
||||
targetType
|
||||
page = 1,
|
||||
bowType,
|
||||
distance,
|
||||
targetType
|
||||
) => {
|
||||
let url = `/user/score/sheet/list?pageNum=${page}&pageSize=10`;
|
||||
if (bowType) url += `&bowType=${bowType}`;
|
||||
if (distance) url += `&distance=${distance}`;
|
||||
if (targetType) url += `&targetType=${targetType}`;
|
||||
const result = await request("GET", url);
|
||||
return result.list || [];
|
||||
let url = `/user/score/sheet/list?pageNum=${page}&pageSize=10`;
|
||||
if (bowType) url += `&bowType=${bowType}`;
|
||||
if (distance) url += `&distance=${distance}`;
|
||||
if (targetType) url += `&targetType=${targetType}`;
|
||||
const result = await request("GET", url);
|
||||
return result.list || [];
|
||||
};
|
||||
|
||||
export const getPointBookDetailAPI = async (id) => {
|
||||
return request("GET", `/user/score/sheet/detail?id=${id}`);
|
||||
return request("GET", `/user/score/sheet/detail?id=${id}`);
|
||||
};
|
||||
|
||||
export const getPointBookDataAPI = async () => {
|
||||
return request("GET", "/user/score/sheet/statistics");
|
||||
return request("GET", "/user/score/sheet/statistics");
|
||||
};
|
||||
|
||||
export const getPractiseDataAPI = async () => {
|
||||
return request("GET", "/user/practice/statistics");
|
||||
return request("GET", "/user/practice/statistics");
|
||||
};
|
||||
|
||||
export const getBattleDataAPI = async () => {
|
||||
return request("GET", "/user/fight/statistics");
|
||||
return request("GET", "/user/fight/statistics");
|
||||
};
|
||||
|
||||
export const chooseTeamAPI = async (number, group) => {
|
||||
return request("POST", "/user/room/group", { number, group });
|
||||
return request("POST", "/user/room/group", {number, group});
|
||||
};
|
||||
|
||||
export const getVIPDescAPI = async () => {
|
||||
return request("GET", "/index/memberVipDescribe");
|
||||
return request("GET", "/index/memberVipDescribe");
|
||||
};
|
||||
|
||||
export const getPointBookStatisticsAPI = async () => {
|
||||
return request("GET", `/v2/user/score/sheet/statistics`);
|
||||
return request("GET", `/v2/user/score/sheet/statistics`);
|
||||
};
|
||||
|
||||
export const donateAPI = async (amount, name, phone, organizer, advice) => {
|
||||
return request("POST", `/user/donate`, {
|
||||
amount,
|
||||
name,
|
||||
phone,
|
||||
organizer,
|
||||
advice,
|
||||
});
|
||||
return request("POST", `/user/donate`, {
|
||||
amount,
|
||||
name,
|
||||
phone,
|
||||
organizer,
|
||||
advice,
|
||||
});
|
||||
};
|
||||
|
||||
export const laserAimAPI = async () => {
|
||||
return request("POST", "/user/device/laserAim");
|
||||
};
|
||||
|
||||
export const laserCloseAPI = async () => {
|
||||
return request("POST", "/user/device/closeAim");
|
||||
};
|
||||
|
||||
export const getDeviceBatteryAPI = async () => {
|
||||
return request("GET", "/user/device/battery");
|
||||
};
|
||||
|
||||
export const addNoteAPI = async (id, remark) => {
|
||||
return request("POST", "/user/score/sheet/remark", {id, remark});
|
||||
};
|
||||
|
||||
export const removePointRecord = async (id) => {
|
||||
return request("DELETE", `/user/score/sheet/delete?id=${id}`);
|
||||
};
|
||||
|
||||
export const getPhoneNumberAPI = (data) => {
|
||||
return request("POST", "/index/getPhone", data);
|
||||
};
|
||||
|
||||
export const getPointBookRankListAPI = (page = 1) => {
|
||||
return request(
|
||||
"GET",
|
||||
`/user/score/sheet/week/shoot/rank/list?pageNum=${page}&pageSize=100`
|
||||
);
|
||||
};
|
||||
|
||||
export const clickLikeAPI = (userId, ifLike) => {
|
||||
return request("POST", "/user/score/sheet/week/shoot/rank/like", {
|
||||
userId,
|
||||
ifLike,
|
||||
});
|
||||
};
|
||||
|
||||
export const getMyLikeList = (page = 1, pageSize = 10) => {
|
||||
return request(
|
||||
"GET",
|
||||
`/user/score/sheet/week/shoot/rank/like/list?pageNum=${page}&pageSize=${pageSize}`
|
||||
);
|
||||
};
|
||||
|
||||
export const getReadyAPI = (roomId) => {
|
||||
return request("POST", `/user/room/ready`, {
|
||||
roomId,
|
||||
});
|
||||
};
|
||||
|
||||
export const getBattleAPI = async (battleId) => {
|
||||
return request("POST", "/user/match/info", {
|
||||
id: battleId,
|
||||
});
|
||||
};
|
||||
|
||||
export const kickPlayerAPI = (number, userId) => {
|
||||
return request("POST", "/user/room/kicking", {
|
||||
number,
|
||||
userId,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取赛季列表
|
||||
export const getSeasonList = () => {
|
||||
return request("GET", "/index/season/list");
|
||||
};
|
||||
|
||||
// 获取赛季统计
|
||||
export const getSeasonStats = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/season/stats", data);
|
||||
};
|
||||
|
||||
//获取积分榜
|
||||
export const getScoreRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/score/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取10环排行榜
|
||||
export const getTenRingRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/tenRing/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取MVP排行榜
|
||||
export const getMvpRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/mvp/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取我的积分排名
|
||||
export const getMyScoreRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myScoreRank", data);
|
||||
};
|
||||
|
||||
// 获取我的MVP排名
|
||||
export const getMyMvpRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myMvpRank", data);
|
||||
};
|
||||
|
||||
// 获取我的10环排名
|
||||
export const getMyTenRingRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myTenRingRank", data);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
const audioFils = {
|
||||
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
|
||||
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
|
||||
|
||||
export const audioFils = {
|
||||
tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3",
|
||||
点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3",
|
||||
"20CM全环靶": "https://static.shelingxingqiu.com/shootmini/static/audio/20CM%E5%85%A8%E7%8E%AF%E9%9D%B6-%E6%97%A0%E6%95%88.mp3",
|
||||
"40CM全环靶": "https://static.shelingxingqiu.com/shootmini/static/audio/40CM%E5%85%A8%E7%8E%AF%E9%9D%B6-%E6%97%A0%E6%95%88.mp3",
|
||||
// 激光已校准:
|
||||
// "https://static.shelingxingqiu.com/attachment/2025-10-29/ddupaur1vdkyhzaqdc.mp3",
|
||||
胜利: "https://static.shelingxingqiu.com/attachment/2025-09-17/dcuo9yjp0kt5msvmvd.mp3",
|
||||
失败: "https://static.shelingxingqiu.com/attachment/2025-09-17/dcuo9yht2sdwhuqygy.mp3",
|
||||
请射箭测试距离:
|
||||
@@ -6,7 +15,7 @@ const audioFils = {
|
||||
距离合格:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutwrda0amn5kqr4j.mp3",
|
||||
距离不足:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutya57qurnsj6pg4.mp3",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6hr2faw28t0ianh0.mp3",
|
||||
轮到你了:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrn4lxcpv8aqr.mp3",
|
||||
第一轮:
|
||||
@@ -34,29 +43,54 @@ const audioFils = {
|
||||
请开始射击:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrl5u0iromqhf.mp3",
|
||||
射击无效:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutya55ufiiw8oo55.mp3",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/audio/%E5%B0%84%E7%AE%AD%E6%97%A0%E6%95%88%E6%A3%80%E6%9F%A5%E8%B7%9D%E7%A6%BB%E5%92%8C%E9%9D%B6%E7%BA%B8.mp3",
|
||||
未上靶:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcuuznjc78ljhzuw1o.mp3",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6n45o3tsm1v4unam.mp3",
|
||||
"1环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin1aq7gxjih5l.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/1.mp3",
|
||||
"2环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin64tdgx2s4at.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/2.mp3",
|
||||
"3环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxinlmf87vt8z65.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/3.mp3",
|
||||
"4环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxinniv97sx0q9u.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/4.mp3",
|
||||
"5环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin7j01kknpb7k.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/5.mp3",
|
||||
"6环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin4syy1015rtq.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/6.mp3",
|
||||
"7环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin3iz3dvmjdai.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/7.mp3",
|
||||
"8环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxinnjd42lhpfiw.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/8.mp3",
|
||||
"9环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxin69nj1xh7yfz.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/9.mp3",
|
||||
"10环":
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-17/dcutxinnvsx0tt7ksa.mp3",
|
||||
"https://static.shelingxingqiu.com/shootaudio/v3/10.mp3",
|
||||
X环: "https://static.shelingxingqiu.com/shootaudio/v4/v4/X%E7%8E%AF.mp3",
|
||||
向上调整:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellf5pfvu3l8dhr.mp3",
|
||||
向右上调整:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellf45v88pirarr.mp3",
|
||||
向右调整:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6elleqnhrenggxsb.mp3",
|
||||
向右下调整:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6elleo6q16qctf6a.mp3",
|
||||
向下调整:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellek2mu2cri2n9.mp3",
|
||||
向左下调整:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellf25yu1pt2k5r.mp3",
|
||||
向左调整:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellen3zoalxcb06.mp3",
|
||||
向左上调整:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-12/de6ellf37a2iw6w4pu.mp3",
|
||||
最后30秒:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-13/de7kzzllq0futwynso.mp3",
|
||||
练习开始:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-11-14/de88w0lmmt43nnfmoi.mp3",
|
||||
射箭声音:
|
||||
"https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3",
|
||||
命中:
|
||||
"https://static.shelingxingqiu.com/shootaudio/%E5%91%BD%E4%B8%AD.mp3"
|
||||
};
|
||||
|
||||
// 版本控制日志函数
|
||||
@@ -66,7 +100,7 @@ function debugLog(...args) {
|
||||
const envVersion = accountInfo.miniProgram.envVersion;
|
||||
|
||||
// 只在体验版打印日志,正式版(release)和开发版(develop)不打印
|
||||
if (envVersion === 'trial') {
|
||||
if (envVersion === "trial" || envVersion === "develop") {
|
||||
console.log(...args);
|
||||
}
|
||||
}
|
||||
@@ -75,8 +109,12 @@ class AudioManager {
|
||||
constructor() {
|
||||
this.audioMap = new Map();
|
||||
this.currentPlayingKey = null;
|
||||
this.retryCount = new Map();
|
||||
this.maxRetries = 3;
|
||||
// 多轮统一重试:最多重试的轮次与每轮间隔
|
||||
this.maxRetryRounds = 10;
|
||||
this.retryRoundIntervalMs = 1500;
|
||||
// 显式授权播放标记,防止 iOS 在设置 src 后误播
|
||||
this.allowPlayMap = new Map();
|
||||
|
||||
// 串行加载相关属性
|
||||
this.audioKeys = [];
|
||||
@@ -84,141 +122,531 @@ class AudioManager {
|
||||
this.isLoading = false;
|
||||
this.loadingPromise = null;
|
||||
|
||||
// 连续播放队列相关属性
|
||||
this.sequenceQueue = [];
|
||||
this.sequenceIndex = 0;
|
||||
this.isSequenceRunning = false;
|
||||
|
||||
// 防重复播放保护
|
||||
this.lastPlayKey = null;
|
||||
this.lastPlayAt = 0;
|
||||
this.isInterrupted = false;
|
||||
|
||||
// 静音开关
|
||||
this.isMuted = false;
|
||||
this.pendingPlayKey = null;
|
||||
// 新增:就绪状态映射
|
||||
this.readyMap = new Map();
|
||||
// 新增:首轮失败的音频集合与重试阶段标识
|
||||
this.failedLoadKeys = new Set();
|
||||
// 加载代数,用于 reloadAll 时作废旧的加载循环
|
||||
this.loadGeneration = 0;
|
||||
// 本地路径缓存 Map: { url: localPath }
|
||||
this.localFileCache = uni.getStorageSync("audio_local_files") || {};
|
||||
// 启动时自动清理过期的缓存文件(URL 已不在 audioFils 中的文件)
|
||||
this.cleanObsoleteCache();
|
||||
this.bindAudioInterruptionEvents();
|
||||
|
||||
this.initAudios();
|
||||
}
|
||||
|
||||
// 初始化音频
|
||||
bindAudioInterruptionEvents() {
|
||||
if (this._audioInterruptionBound) return;
|
||||
this._audioInterruptionBound = true;
|
||||
|
||||
const begin = () => {
|
||||
if (this.isInterrupted) return;
|
||||
this.isInterrupted = true;
|
||||
this.stopAll();
|
||||
this.isSequenceRunning = false;
|
||||
this.sequenceQueue = [];
|
||||
this.sequenceIndex = 0;
|
||||
this.pendingPlayKey = null;
|
||||
uni.$emit(AUDIO_INTERRUPTION_BEGIN_EVENT);
|
||||
};
|
||||
|
||||
const end = () => {
|
||||
if (!this.isInterrupted) return;
|
||||
this.isInterrupted = false;
|
||||
uni.$emit(AUDIO_INTERRUPTION_END_EVENT);
|
||||
void this.reloadAll();
|
||||
};
|
||||
|
||||
if (typeof uni?.onAudioInterruptionBegin === "function") {
|
||||
uni.onAudioInterruptionBegin(begin);
|
||||
}
|
||||
if (typeof uni?.onAudioInterruptionEnd === "function") {
|
||||
uni.onAudioInterruptionEnd(end);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理不再使用的缓存文件
|
||||
cleanObsoleteCache() {
|
||||
const activeUrls = new Set(Object.values(audioFils));
|
||||
const cachedUrls = Object.keys(this.localFileCache);
|
||||
let hasChanges = false;
|
||||
|
||||
for (const url of cachedUrls) {
|
||||
if (!activeUrls.has(url)) {
|
||||
debugLog(`发现废弃音频缓存,正在清理: ${url}`);
|
||||
const path = this.localFileCache[url];
|
||||
// 移除物理文件
|
||||
uni.removeSavedFile({
|
||||
filePath: path,
|
||||
complete: () => {
|
||||
// 忽略移除结果,直接移除记录
|
||||
},
|
||||
});
|
||||
// 移除记录
|
||||
delete this.localFileCache[url];
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
uni.setStorageSync("audio_local_files", this.localFileCache);
|
||||
debugLog("废弃缓存清理完成");
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化音频(两阶段:首轮串行加载全部,次轮仅串行加载失败项一次)
|
||||
initAudios() {
|
||||
if (this.isLoading) {
|
||||
debugLog("音频正在加载中,跳过重复初始化");
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
debugLog("开始串行加载音频...");
|
||||
this.isLoading = true;
|
||||
this.audioKeys = Object.keys(audioFils);
|
||||
this.currentLoadingIndex = 0;
|
||||
this.failedLoadKeys.clear();
|
||||
|
||||
// 增加代数,使得旧的加载循环失效
|
||||
this.loadGeneration = (this.loadGeneration || 0) + 1;
|
||||
const currentGen = this.loadGeneration;
|
||||
|
||||
this.loadingPromise = new Promise((resolve) => {
|
||||
this.loadNextAudio(resolve);
|
||||
});
|
||||
const finalize = () => {
|
||||
if (currentGen !== this.loadGeneration) return;
|
||||
const runRounds = (round) => {
|
||||
if (currentGen !== this.loadGeneration) return;
|
||||
// 达到最大轮次或没有失败项,收尾
|
||||
if (this.failedLoadKeys.size === 0 || round > this.maxRetryRounds) {
|
||||
this.isLoading = false;
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const retryKeys = Array.from(this.failedLoadKeys);
|
||||
this.failedLoadKeys.clear();
|
||||
debugLog(`开始第 ${round} 轮串行加载,共 ${retryKeys.length} 个`);
|
||||
|
||||
this.loadKeysSequentially(
|
||||
retryKeys,
|
||||
() => {
|
||||
if (currentGen !== this.loadGeneration) return;
|
||||
// 如仍有失败项,继续下一轮;否则结束
|
||||
if (this.failedLoadKeys.size > 0 && round < this.maxRetryRounds) {
|
||||
setTimeout(
|
||||
() => runRounds(round + 1),
|
||||
this.retryRoundIntervalMs
|
||||
);
|
||||
} else {
|
||||
this.isLoading = false;
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
currentGen
|
||||
);
|
||||
};
|
||||
|
||||
// 启动第 1 轮重试(如有失败项)
|
||||
runRounds(1);
|
||||
};
|
||||
this.loadNextAudio(finalize, currentGen);
|
||||
});
|
||||
return this.loadingPromise;
|
||||
}
|
||||
|
||||
// 串行加载下一个音频
|
||||
loadNextAudio(onComplete) {
|
||||
// 按自定义列表串行加载音频(避免并发过多)
|
||||
loadKeysSequentially(keys, onComplete, gen) {
|
||||
if (gen !== undefined && gen !== this.loadGeneration) return;
|
||||
let idx = 0;
|
||||
const list = Array.from(keys);
|
||||
const next = () => {
|
||||
if (gen !== undefined && gen !== this.loadGeneration) return;
|
||||
if (idx >= list.length) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const k = list[idx++];
|
||||
|
||||
// 已就绪的音频不再重载,避免把 ready 状态重置为 false
|
||||
if (this.readyMap.get(k)) {
|
||||
setTimeout(next, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
// 未就绪:已存在则重载;不存在则创建
|
||||
if (this.audioMap.has(k)) {
|
||||
this.retryLoadAudio(k);
|
||||
setTimeout(next, 100);
|
||||
} else {
|
||||
this.createAudio(k, () => {
|
||||
setTimeout(next, 100);
|
||||
});
|
||||
return; // createAudio 内部会触发 next
|
||||
}
|
||||
};
|
||||
next();
|
||||
}
|
||||
|
||||
// 串行加载下一个音频(首轮)
|
||||
loadNextAudio(onComplete, gen) {
|
||||
if (gen !== undefined && gen !== this.loadGeneration) return;
|
||||
if (this.currentLoadingIndex >= this.audioKeys.length) {
|
||||
debugLog("所有音频加载完成");
|
||||
this.isLoading = false;
|
||||
debugLog("首轮加载遍历完成", this.currentLoadingIndex);
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = this.audioKeys[this.currentLoadingIndex];
|
||||
debugLog(`开始加载音频 ${this.currentLoadingIndex + 1}/${this.audioKeys.length}: ${key}`);
|
||||
|
||||
debugLog(
|
||||
`开始加载音频 ${this.currentLoadingIndex + 1}/${
|
||||
this.audioKeys.length
|
||||
}: ${key}`
|
||||
);
|
||||
this.createAudio(key, () => {
|
||||
this.currentLoadingIndex++;
|
||||
setTimeout(() => {
|
||||
this.loadNextAudio(onComplete);
|
||||
this.loadNextAudio(onComplete, gen);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// 创建单个音频实例
|
||||
// 创建单个音频实例(支持本地缓存)
|
||||
createAudio(key, callback) {
|
||||
this.currentLoadingIndex++;
|
||||
const src = audioFils[key];
|
||||
const audio = uni.createInnerAudioContext();
|
||||
audio.src = src;
|
||||
audio.autoplay = false;
|
||||
|
||||
// 设置加载超时
|
||||
const loadTimeout = setTimeout(() => {
|
||||
debugLog(`音频 ${key} 加载超时`);
|
||||
audio.destroy();
|
||||
if (callback) callback();
|
||||
}, 10000);
|
||||
const setupAudio = (realSrc) => {
|
||||
const audio = uni.createInnerAudioContext();
|
||||
audio.autoplay = false;
|
||||
audio.src = realSrc;
|
||||
try {
|
||||
if (typeof audio.volume === "number") {
|
||||
audio.volume = this.isMuted ? 0 : 1;
|
||||
} else if (typeof audio.muted !== "undefined") {
|
||||
audio.muted = this.isMuted;
|
||||
}
|
||||
} catch (_) {}
|
||||
this.allowPlayMap.set(key, false);
|
||||
audio.onPlay(() => {
|
||||
if (!this.allowPlayMap.get(key)) {
|
||||
try {
|
||||
audio.stop();
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
|
||||
// 监听加载状态
|
||||
audio.onCanplay(() => {
|
||||
clearTimeout(loadTimeout);
|
||||
debugLog(`音频 ${key} 已加载完成`);
|
||||
this.retryCount.set(key, 0);
|
||||
if (callback) callback();
|
||||
});
|
||||
const loadTimeout = setTimeout(() => {
|
||||
debugLog(`音频 ${key} 加载超时`);
|
||||
this.recordLoadFailure(key);
|
||||
try {
|
||||
audio.destroy();
|
||||
} catch (_) {}
|
||||
if (callback) callback();
|
||||
}, 10000);
|
||||
|
||||
audio.onError((res) => {
|
||||
clearTimeout(loadTimeout);
|
||||
debugLog(`音频 ${key} 加载失败:`, res.errMsg);
|
||||
this.handleAudioError(key);
|
||||
if (callback) callback();
|
||||
});
|
||||
audio.onCanplay(() => {
|
||||
if (!this.allowPlayMap.get(key)) {
|
||||
try {
|
||||
audio.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
clearTimeout(loadTimeout);
|
||||
this.readyMap.set(key, true);
|
||||
this.failedLoadKeys.delete(key);
|
||||
// debugLog(`音频 ${key} 已加载完成`, this.getLoadProgress());
|
||||
uni.$emit("audioLoaded", key);
|
||||
const loadedAudioKeys = uni.getStorageSync("loadedAudioKeys") || {};
|
||||
loadedAudioKeys[key] = true;
|
||||
uni.setStorageSync("loadedAudioKeys", loadedAudioKeys);
|
||||
if (callback) callback();
|
||||
});
|
||||
|
||||
// 监听播放结束事件
|
||||
audio.onEnded(() => {
|
||||
if (this.currentPlayingKey === key) {
|
||||
this.currentPlayingKey = null;
|
||||
audio.onError((res) => {
|
||||
clearTimeout(loadTimeout);
|
||||
debugLog(`音频 ${key} 加载失败:`, res.errMsg);
|
||||
// 如果是本地文件加载失败,可能是文件损坏,清除缓存以便下次重新下载
|
||||
if (realSrc !== src && this.localFileCache[src] === realSrc) {
|
||||
debugLog(`本地缓存失效,移除记录: ${key}`);
|
||||
delete this.localFileCache[src];
|
||||
uni.setStorageSync("audio_local_files", this.localFileCache);
|
||||
// 移除文件
|
||||
uni.removeSavedFile({ filePath: realSrc });
|
||||
}
|
||||
this.recordLoadFailure(key);
|
||||
this.audioMap.delete(key);
|
||||
audio.destroy();
|
||||
if (this.readyMap.get(key)) {
|
||||
// 这里不要去除,不然检查进度的时候由于没有重新加载而进度卡住,等播放失败的时候会重新加载
|
||||
// this.readyMap.set(key, false);
|
||||
} else {
|
||||
if (callback) callback();
|
||||
}
|
||||
});
|
||||
|
||||
audio.onEnded(() => {
|
||||
if (this.currentPlayingKey === key) {
|
||||
this.currentPlayingKey = null;
|
||||
}
|
||||
this.allowPlayMap.set(key, false);
|
||||
this.onAudioEnded(key);
|
||||
uni.$emit('audioEnded', key);
|
||||
});
|
||||
|
||||
audio.onStop(() => {
|
||||
if (this.currentPlayingKey === key) {
|
||||
this.currentPlayingKey = null;
|
||||
}
|
||||
this.allowPlayMap.set(key, false);
|
||||
});
|
||||
|
||||
this.audioMap.set(key, audio);
|
||||
};
|
||||
|
||||
// 检查是否有可用的本地缓存
|
||||
this.checkLocalFile(src).then((localPath) => {
|
||||
if (localPath) {
|
||||
debugLog(`命中本地缓存: ${key}`);
|
||||
setupAudio(localPath);
|
||||
} else {
|
||||
// 下载并尝试保存
|
||||
uni.downloadFile({
|
||||
url: src,
|
||||
timeout: 20000,
|
||||
success: (res) => {
|
||||
if (res.tempFilePath) {
|
||||
// 尝试保存文件到本地存储(持久化)
|
||||
uni.getFileSystemManager().saveFile({
|
||||
tempFilePath: res.tempFilePath,
|
||||
success: (saveRes) => {
|
||||
const savedPath = saveRes.savedFilePath;
|
||||
this.localFileCache[src] = savedPath;
|
||||
uni.setStorageSync("audio_local_files", this.localFileCache);
|
||||
debugLog(`音频已缓存到本地: ${key}`);
|
||||
setupAudio(savedPath);
|
||||
},
|
||||
fail: (err) => {
|
||||
debugLog(
|
||||
`保存音频失败(可能空间不足),使用临时文件: ${key}`,
|
||||
err
|
||||
);
|
||||
setupAudio(res.tempFilePath);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.recordLoadFailure(key);
|
||||
if (callback) callback();
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
this.recordLoadFailure(key);
|
||||
if (callback) callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 监听播放停止事件
|
||||
audio.onStop(() => {
|
||||
if (this.currentPlayingKey === key) {
|
||||
this.currentPlayingKey = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.audioMap.set(key, audio);
|
||||
if (!this.retryCount.has(key)) {
|
||||
this.retryCount.set(key, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理音频加载错误
|
||||
handleAudioError(key) {
|
||||
const currentRetries = this.retryCount.get(key) || 0;
|
||||
|
||||
if (currentRetries < this.maxRetries) {
|
||||
this.retryCount.set(key, currentRetries + 1);
|
||||
debugLog(`音频 ${key} 开始第 ${currentRetries + 1} 次重试...`);
|
||||
|
||||
setTimeout(() => {
|
||||
this.retryLoadAudio(key);
|
||||
}, 1000);
|
||||
} else {
|
||||
console.error(`音频 ${key} 重试 ${this.maxRetries} 次后仍然失败,停止重试`);
|
||||
const failedAudio = this.audioMap.get(key);
|
||||
if (failedAudio) {
|
||||
failedAudio.destroy();
|
||||
this.audioMap.delete(key);
|
||||
// 检查本地文件是否有效
|
||||
checkLocalFile(url) {
|
||||
return new Promise((resolve) => {
|
||||
const path = this.localFileCache[url];
|
||||
if (!path) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 检查文件是否存在
|
||||
uni.getFileSystemManager().getFileInfo({
|
||||
filePath: path,
|
||||
success: () => {
|
||||
resolve(path);
|
||||
},
|
||||
fail: () => {
|
||||
// 文件不存在,清理记录
|
||||
delete this.localFileCache[url];
|
||||
uni.setStorageSync("audio_local_files", this.localFileCache);
|
||||
resolve(null);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 新增:记录失败(首轮与次轮都会用到)
|
||||
recordLoadFailure(key) {
|
||||
this.failedLoadKeys.add(key);
|
||||
}
|
||||
|
||||
// 重新加载音频
|
||||
retryLoadAudio(key) {
|
||||
const oldAudio = this.audioMap.get(key);
|
||||
if (oldAudio) {
|
||||
oldAudio.destroy();
|
||||
}
|
||||
if (oldAudio) oldAudio.destroy();
|
||||
this.createAudio(key);
|
||||
}
|
||||
|
||||
// 播放指定音频
|
||||
play(key) {
|
||||
// 如果有正在播放的音频,先停止
|
||||
// 播放指定音频或音频数组(数组则按顺序连续播放)
|
||||
play(input, interrupt = true) {
|
||||
if (this.isInterrupted) {
|
||||
debugLog("音频处理中断状态,忽略播放请求");
|
||||
return;
|
||||
}
|
||||
// 统一规范化为队列
|
||||
let queue = [];
|
||||
if (Array.isArray(input)) {
|
||||
queue = input.filter((k) => !!audioFils[k]);
|
||||
} else if (typeof input === "string") {
|
||||
queue = !!audioFils[input] ? [input] : [];
|
||||
} else {
|
||||
debugLog("play 参数类型无效,仅支持字符串或字符串数组");
|
||||
return;
|
||||
}
|
||||
|
||||
if (queue.length === 0) {
|
||||
debugLog("连续播放队列为空或无效");
|
||||
return;
|
||||
}
|
||||
|
||||
if (interrupt) {
|
||||
// 立即打断并启动新的播放序列
|
||||
this.stopAll();
|
||||
this.isSequenceRunning = false;
|
||||
this.sequenceQueue = [];
|
||||
this.sequenceIndex = 0;
|
||||
|
||||
this.sequenceQueue = queue;
|
||||
this.sequenceIndex = 0;
|
||||
this.isSequenceRunning = true;
|
||||
this._playSingle(queue[0], false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 不打断当前播放:把新的队列加入到序列中,等待当前播放结束后衔接
|
||||
if (this.currentPlayingKey) {
|
||||
if (this.isSequenceRunning) {
|
||||
// 已有序列在跑:直接追加
|
||||
this.sequenceQueue = this.sequenceQueue.concat(queue);
|
||||
} else {
|
||||
// 没有序列但当前有正在播放的:以当前为序列的起点
|
||||
this.isSequenceRunning = true;
|
||||
this.sequenceQueue = [this.currentPlayingKey].concat(queue);
|
||||
this.sequenceIndex = 0;
|
||||
// 不触发 _playSingle,等待当前音频自然结束后由 onAudioEnded 接管
|
||||
}
|
||||
} else {
|
||||
// 当前没有播放:直接启动新的序列
|
||||
this.sequenceQueue = queue;
|
||||
this.sequenceIndex = 0;
|
||||
this.isSequenceRunning = true;
|
||||
this._playSingle(queue[0], false);
|
||||
}
|
||||
}
|
||||
|
||||
// 内部方法:播放单个 key
|
||||
_playSingle(key, forceStopAll = false) {
|
||||
if (this.isInterrupted) {
|
||||
debugLog(`音频处理中断状态,跳过播放: ${key}`);
|
||||
return;
|
||||
}
|
||||
// 200ms 内的同 key 重复播放直接忽略,避免“比比赛开始”这类重复首音
|
||||
const now = Date.now();
|
||||
if (this.lastPlayKey === key && now - this.lastPlayAt < 250) {
|
||||
debugLog(`忽略快速重复播放: ${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (forceStopAll) {
|
||||
this.stopAll();
|
||||
} else if (this.currentPlayingKey && this.currentPlayingKey !== key) {
|
||||
this.stop(this.currentPlayingKey);
|
||||
} else if (this.currentPlayingKey === key) {
|
||||
// 同一音频正在播放:不重启,避免听到重复开头
|
||||
return;
|
||||
}
|
||||
|
||||
const audio = this.audioMap.get(key);
|
||||
if (audio) {
|
||||
audio.play();
|
||||
// 播放前确保遵循当前静音状态
|
||||
try {
|
||||
if (typeof audio.volume === "number") {
|
||||
audio.volume = this.isMuted ? 0 : 1;
|
||||
} else if (typeof audio.muted !== "undefined") {
|
||||
audio.muted = this.isMuted;
|
||||
}
|
||||
} catch (_) {}
|
||||
// 同一音频:避免 stop() 触发 onStop 清除授权,使用 pause()+seek(0)
|
||||
try {
|
||||
audio.pause();
|
||||
} catch (_) {}
|
||||
try {
|
||||
if (typeof audio.seek === "function") {
|
||||
audio.seek(0);
|
||||
} else {
|
||||
audio.startTime = 0;
|
||||
}
|
||||
} catch (_) {
|
||||
audio.startTime = 0;
|
||||
}
|
||||
|
||||
// 显式授权播放并立即播放
|
||||
this.allowPlayMap.set(key, true);
|
||||
|
||||
try {
|
||||
audio.play();
|
||||
} catch (err) {
|
||||
this.allowPlayMap.set(key, false);
|
||||
debugLog(`音频 ${key} 播放调用失败`, err?.errMsg || err);
|
||||
return;
|
||||
}
|
||||
this.currentPlayingKey = key;
|
||||
this.lastPlayKey = key;
|
||||
this.lastPlayAt = Date.now();
|
||||
} else {
|
||||
debugLog(`音频 ${key} 不存在,尝试重新加载...`);
|
||||
this.reloadAudio(key);
|
||||
this.retryLoadAudio(key);
|
||||
const handler = (loadedKey) => {
|
||||
if (loadedKey === key) {
|
||||
try {
|
||||
uni.$off("audioLoaded", handler);
|
||||
} catch (_) {}
|
||||
// 再次校验是否存在且就绪
|
||||
const a = this.audioMap.get(key);
|
||||
if (a && this.readyMap.get(key)) {
|
||||
this._playSingle(key, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
try {
|
||||
uni.$on("audioLoaded", handler);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// 连续播放:在某个音频结束后,若处于队列播放状态则继续下一个
|
||||
onAudioEnded(key) {
|
||||
if (!this.isSequenceRunning) return;
|
||||
const currentKey = this.sequenceQueue[this.sequenceIndex];
|
||||
if (currentKey !== key) return;
|
||||
|
||||
const nextIndex = this.sequenceIndex + 1;
|
||||
if (nextIndex < this.sequenceQueue.length) {
|
||||
this.sequenceIndex = nextIndex;
|
||||
const nextKey = this.sequenceQueue[nextIndex];
|
||||
this._playSingle(nextKey, false);
|
||||
} else {
|
||||
// 队列播放完成
|
||||
this.isSequenceRunning = false;
|
||||
this.sequenceQueue = [];
|
||||
this.sequenceIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,19 +655,104 @@ class AudioManager {
|
||||
const audio = this.audioMap.get(key);
|
||||
if (audio) {
|
||||
audio.stop();
|
||||
this.allowPlayMap.set(key, false);
|
||||
if (this.currentPlayingKey === key) {
|
||||
this.currentPlayingKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 手动重新加载指定音频
|
||||
reloadAudio(key) {
|
||||
if (audioFils[key]) {
|
||||
debugLog(`手动重新加载音频: ${key}`);
|
||||
this.retryCount.set(key, 0);
|
||||
this.retryLoadAudio(key);
|
||||
// 停止所有音频
|
||||
stopAll() {
|
||||
for (const [k, audio] of this.audioMap.entries()) {
|
||||
try {
|
||||
audio.stop();
|
||||
} catch (_) {}
|
||||
this.allowPlayMap.set(k, false);
|
||||
}
|
||||
this.currentPlayingKey = null;
|
||||
}
|
||||
|
||||
// 设置静音开关:true 静音,false 取消静音
|
||||
setMuted(muted) {
|
||||
this.isMuted = !!muted;
|
||||
for (const audio of this.audioMap.values()) {
|
||||
try {
|
||||
if (typeof audio.volume === "number") {
|
||||
audio.volume = this.isMuted ? 0 : 1;
|
||||
} else if (typeof audio.muted !== "undefined") {
|
||||
audio.muted = this.isMuted;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
debugLog(`静音状态已设置为: ${this.isMuted}`);
|
||||
}
|
||||
|
||||
// 新增:返回音频加载进度(0~1)
|
||||
getLoadProgress() {
|
||||
const keys = Object.keys(audioFils);
|
||||
const total = keys.length;
|
||||
if (total === 0) return 0;
|
||||
let loaded = 0;
|
||||
for (const k of keys) {
|
||||
if (this.readyMap.get(k)) loaded++;
|
||||
}
|
||||
return Number((loaded / total).toFixed(2));
|
||||
}
|
||||
|
||||
// 清理本地音频缓存文件
|
||||
clearCache() {
|
||||
debugLog("开始清理本地音频缓存...");
|
||||
const cache = uni.getStorageSync("audio_local_files") || {};
|
||||
const paths = Object.values(cache);
|
||||
for (const path of paths) {
|
||||
uni.removeSavedFile({
|
||||
filePath: path,
|
||||
complete: (res) => {
|
||||
// 无论成功失败都继续
|
||||
},
|
||||
});
|
||||
}
|
||||
uni.removeStorageSync("audio_local_files");
|
||||
this.localFileCache = {};
|
||||
debugLog("本地音频缓存清理完成");
|
||||
}
|
||||
|
||||
// 手动重置并重新加载所有音频(用于卡住时恢复)
|
||||
reloadAll() {
|
||||
debugLog("执行 reloadAll: 重置所有状态并重新加载");
|
||||
|
||||
// 1. 停止所有播放
|
||||
this.stopAll();
|
||||
|
||||
// 2. 销毁现有音频实例
|
||||
for (const audio of this.audioMap.values()) {
|
||||
try {
|
||||
audio.destroy();
|
||||
} catch (_) {}
|
||||
}
|
||||
this.audioMap.clear();
|
||||
|
||||
// 3. 重置状态
|
||||
this.readyMap.clear();
|
||||
this.failedLoadKeys.clear();
|
||||
this.allowPlayMap.clear();
|
||||
this.currentPlayingKey = null;
|
||||
this.sequenceQueue = [];
|
||||
this.sequenceIndex = 0;
|
||||
this.isSequenceRunning = false;
|
||||
|
||||
// 清理一下可能损坏的缓存(可选,如果用户因为缓存坏了卡住,这一步很有用)
|
||||
// 这里选择不自动全清,而是依赖 onError 里的单点清除。如果需要彻底重置,可取消注释:
|
||||
// this.clearCache();
|
||||
|
||||
// 4. 强制重置加载锁
|
||||
this.isLoading = false;
|
||||
this.loadingPromise = null;
|
||||
this.currentLoadingIndex = 0;
|
||||
|
||||
// 5. 重新初始化 (initAudios 会自增 loadGeneration,从而终止之前的任何异步循环)
|
||||
return this.initAudios();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1012
src/canvas.js
Normal file
1012
src/canvas.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { capsuleHeight } from "@/util";
|
||||
|
||||
const props = defineProps({
|
||||
type: {
|
||||
type: Number,
|
||||
@@ -10,11 +11,6 @@ const props = defineProps({
|
||||
default: "#050b19",
|
||||
},
|
||||
});
|
||||
const capsuleHeight = ref(0);
|
||||
onMounted(() => {
|
||||
const menuBtnInfo = uni.getMenuButtonBoundingClientRect();
|
||||
capsuleHeight.value = menuBtnInfo.top + 50 - 9;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -22,33 +18,51 @@ onMounted(() => {
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 0"
|
||||
src="../static/app-bg.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 1"
|
||||
src="../static/app-bg2.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg2.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 2"
|
||||
src="../static/app-bg3.png"
|
||||
:style="{ height: capsuleHeight + 'px' }"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg3.png"
|
||||
:style="{ height: capsuleHeight + 50 + 'px' }"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 3"
|
||||
src="../static/app-bg4.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg4.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 4"
|
||||
src="../static/app-bg5.png"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg5.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 5"
|
||||
src="https://static.shelingxingqiu.com/attachment/2026-01-05/dfgf3b5kp459tfyn0f.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 6"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/rank/rank-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 10"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/vip/vip-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="bg-overlay" v-if="type === 0"></view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -65,7 +79,7 @@ onMounted(() => {
|
||||
|
||||
.bg-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* height: 100%; */
|
||||
}
|
||||
|
||||
.bg-overlay {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
|
||||
const tabs = [
|
||||
{ image: "../static/tab-vip.png" },
|
||||
{ image: "../static/tab-point-book.png" },
|
||||
@@ -9,7 +8,7 @@ const tabs = [
|
||||
function handleTabClick(index) {
|
||||
if (index === 0) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/be-vip",
|
||||
url: "/pages/member/be-vip",
|
||||
});
|
||||
}
|
||||
if (index === 1) {
|
||||
@@ -32,9 +31,9 @@ function handleTabClick(index) {
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
class="tab-item"
|
||||
@click="handleTabClick(index)"
|
||||
@click="$clickSound(() => handleTabClick(index))"
|
||||
:style="{
|
||||
width: index === 1 ? '36%' : '10%',
|
||||
width: index === 1 ? '36%' : '20%',
|
||||
}"
|
||||
>
|
||||
<image :src="tab.image" mode="widthFix" />
|
||||
@@ -44,13 +43,13 @@ function handleTabClick(index) {
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
height: 117px;
|
||||
height: 120px;
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
overflow-x: hidden;
|
||||
overflow: hidden;
|
||||
}
|
||||
.footer-bg {
|
||||
width: 100%;
|
||||
@@ -64,10 +63,13 @@ function handleTabClick(index) {
|
||||
justify-content: center;
|
||||
}
|
||||
.tab-item > image {
|
||||
width: 86%;
|
||||
width: 65rpx;
|
||||
}
|
||||
.tab-item:last-child > image {
|
||||
width: 85rpx;
|
||||
}
|
||||
.tab-item:nth-child(2) {
|
||||
transform: translate(25%, 30%);
|
||||
transform: translate(10%, 40%);
|
||||
}
|
||||
.tab-item:nth-child(3) {
|
||||
margin-bottom: 25rpx;
|
||||
@@ -76,6 +78,6 @@ function handleTabClick(index) {
|
||||
width: 140rpx;
|
||||
}
|
||||
.tab-item:nth-child(4) {
|
||||
transform: translate(-25%, 30%);
|
||||
transform: translate(-10%, 44%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,50 +1,90 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { isGamingAPI, getCurrentGameAPI } from "@/apis";
|
||||
|
||||
import { getBattleAPI, getUserGameState } from "@/apis";
|
||||
import { debounce } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const { user, game } = storeToRefs(store);
|
||||
const { updateGame } = store;
|
||||
|
||||
const props = defineProps({
|
||||
signin: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
const show = ref(false);
|
||||
const loading = ref(false);
|
||||
const navigating = ref(false);
|
||||
|
||||
/** 统一获取当前环境 token,用于守卫:无有效 token 时不发起接口请求 */
|
||||
const getToken = () =>
|
||||
uni.getStorageSync(`${uni.getAccountInfoSync().miniProgram.envVersion}_token`);
|
||||
|
||||
onShow(async () => {
|
||||
if (user.value.id) {
|
||||
const isGaming = await isGamingAPI();
|
||||
show.value = isGaming;
|
||||
navigating.value = false;
|
||||
if (user.value.id && getToken()) {
|
||||
setTimeout(async () => {
|
||||
const state = await getUserGameState();
|
||||
updateGame(state.gaming, state.roomId);
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => user.value,
|
||||
async (value) => {
|
||||
if (!value.id) {
|
||||
show.value = false;
|
||||
} else {
|
||||
const isGaming = await isGamingAPI();
|
||||
show.value = isGaming;
|
||||
updateGame(false, "");
|
||||
} else if (getToken()) {
|
||||
// 有有效 token 时才查询在局状态,避免 token 失效时反复发起无效请求
|
||||
const state = await getUserGameState();
|
||||
updateGame(state.gaming, state.roomId);
|
||||
}
|
||||
}
|
||||
);
|
||||
const onClick = debounce(async () => {
|
||||
const isGaming = await isGamingAPI();
|
||||
show.value = isGaming;
|
||||
if (isGaming) {
|
||||
const result = await getCurrentGameAPI();
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: "比赛已结束",
|
||||
icon: "none",
|
||||
|
||||
const navigateOnce = (url) =>
|
||||
new Promise((resolve, reject) => {
|
||||
navigating.value = true;
|
||||
uni.navigateTo({
|
||||
url,
|
||||
success: resolve,
|
||||
fail: (error) => {
|
||||
navigating.value = false;
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const onClick = debounce(async () => {
|
||||
if (loading.value || navigating.value) return;
|
||||
try {
|
||||
loading.value = true;
|
||||
const result = await getBattleAPI();
|
||||
if (result && result.matchId) {
|
||||
await uni.$checkAudio();
|
||||
if (result.mode <= 3) {
|
||||
await navigateOnce(`/pages/team-battle/index?battleId=${result.matchId}`);
|
||||
} else {
|
||||
await navigateOnce(`/pages/melee-battle?battleId=${result.matchId}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (game.value.roomID) {
|
||||
await navigateOnce("/pages/battle-room?roomNumber=" + game.value.roomID);
|
||||
} else {
|
||||
updateGame(false, "");
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const gameOver = () => {
|
||||
show.value = false;
|
||||
updateGame(false, "");
|
||||
};
|
||||
onMounted(() => {
|
||||
uni.$on("game-over", gameOver);
|
||||
@@ -55,10 +95,19 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-if="show" class="back-to-game" @click="onClick">
|
||||
<view
|
||||
v-if="game.inBattle || game.roomID"
|
||||
class="back-to-game"
|
||||
@click="onClick"
|
||||
>
|
||||
<image src="../static/back-to-game-bg.png" mode="widthFix" />
|
||||
<image src="../static/pk-icon.png" mode="widthFix" />
|
||||
<text>返回进行中的对局</text>
|
||||
<block v-if="game.inBattle">
|
||||
<image src="../static/pk-icon.png" mode="widthFix" />
|
||||
<text>返回进行中的对局</text>
|
||||
</block>
|
||||
<block v-else-if="game.roomID">
|
||||
<text>返回房间</text>
|
||||
</block>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
</template>
|
||||
@@ -78,16 +127,18 @@ onBeforeUnmount(() => {
|
||||
.back-to-game > image:first-child {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100rpx;
|
||||
}
|
||||
.back-to-game > image:nth-child(2) {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
.back-to-game > text:nth-child(3) {
|
||||
.back-to-game > text {
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
}
|
||||
.back-to-game > image:nth-child(4) {
|
||||
.back-to-game > image:last-child {
|
||||
position: relative;
|
||||
width: 15px;
|
||||
margin-left: 5px;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
=
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import BowPower from "@/components/BowPower.vue";
|
||||
import { RoundImages } from "@/constants";
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
roundResults: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -15,15 +16,16 @@ defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
power: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
goldenRound: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const normalRounds = computed(() => {
|
||||
const count = props.roundResults.findIndex((item) => !!item.ifGold);
|
||||
return count > 0 ? count : props.roundResults.length;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,7 +38,7 @@ defineProps({
|
||||
transform: 'scale(0.8) translateX(10px)',
|
||||
}"
|
||||
>
|
||||
<BowPower :power="power" />
|
||||
<BowPower />
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
@@ -47,15 +49,9 @@ defineProps({
|
||||
<view class="players">
|
||||
<view>
|
||||
<view v-for="(result, index) in roundResults" :key="index">
|
||||
<block
|
||||
v-if="goldenRound > 0 && index >= roundResults.length - goldenRound"
|
||||
>
|
||||
<block v-if="index + 1 > normalRounds">
|
||||
<image
|
||||
:src="
|
||||
RoundImages[
|
||||
`gold${index + 1 - (roundResults.length - goldenRound)}`
|
||||
]
|
||||
"
|
||||
:src="RoundImages[`gold${index + 1 - normalRounds}`]"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</block>
|
||||
@@ -64,8 +60,8 @@ defineProps({
|
||||
</block>
|
||||
<view>
|
||||
<text>{{
|
||||
result.blueArrows.length
|
||||
? result.blueArrows
|
||||
result.shoots[1] && result.shoots[1].length
|
||||
? result.shoots[1]
|
||||
.map((item) => item.ring)
|
||||
.reduce((last, next) => last + next, 0)
|
||||
: ""
|
||||
@@ -88,15 +84,9 @@ defineProps({
|
||||
</view>
|
||||
<view>
|
||||
<view v-for="(result, index) in roundResults" :key="index">
|
||||
<block
|
||||
v-if="goldenRound > 0 && index >= roundResults.length - goldenRound"
|
||||
>
|
||||
<block v-if="index + 1 > normalRounds">
|
||||
<image
|
||||
:src="
|
||||
RoundImages[
|
||||
`gold${index + 1 - (roundResults.length - goldenRound)}`
|
||||
]
|
||||
"
|
||||
:src="RoundImages[`gold${index + 1 - normalRounds}`]"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</block>
|
||||
@@ -105,8 +95,8 @@ defineProps({
|
||||
</block>
|
||||
<view>
|
||||
<text>{{
|
||||
result.redArrows.length
|
||||
? result.redArrows
|
||||
result.shoots[2] && result.shoots[2].length
|
||||
? result.shoots[2]
|
||||
.map((item) => item.ring)
|
||||
.reduce((last, next) => last + next, 0)
|
||||
: ""
|
||||
@@ -135,7 +125,7 @@ defineProps({
|
||||
.container {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-top: -40px;
|
||||
margin-top: -100rpx;
|
||||
}
|
||||
.container > view:nth-child(2) {
|
||||
position: relative;
|
||||
@@ -152,6 +142,11 @@ defineProps({
|
||||
.container > view:nth-child(2) > text {
|
||||
z-index: 1;
|
||||
margin-top: 2px;
|
||||
color: #8a323e;
|
||||
font-weight: 500;
|
||||
}
|
||||
.container > view:nth-child(2) > text:nth-child(2) {
|
||||
color: #004ac1;
|
||||
}
|
||||
.players {
|
||||
display: flex;
|
||||
@@ -166,13 +161,13 @@ defineProps({
|
||||
padding-top: 5px;
|
||||
}
|
||||
.players > view:first-child > view {
|
||||
background: linear-gradient(270deg, #172a86 0%, #0006 100%);
|
||||
background: linear-gradient(270deg, #172a86 0%, #0000 100%);
|
||||
}
|
||||
.players > view:last-child > view {
|
||||
background: linear-gradient(270deg, #0006 0%, #6a1212 100%);
|
||||
background: linear-gradient(270deg, #0000 0%, #6a1212 100%);
|
||||
}
|
||||
.players > view > view {
|
||||
min-height: 25px;
|
||||
min-height: 52rpx;
|
||||
width: calc(100% - 40px);
|
||||
padding: 2px 20px;
|
||||
margin-bottom: 5px;
|
||||
@@ -181,7 +176,7 @@ defineProps({
|
||||
align-items: center;
|
||||
}
|
||||
.players > view > view > image:first-child {
|
||||
width: 72px;
|
||||
width: 135rpx;
|
||||
height: 20px;
|
||||
}
|
||||
.players > view > view > view:last-child {
|
||||
@@ -191,6 +186,7 @@ defineProps({
|
||||
font-size: 16px;
|
||||
color: #fed847;
|
||||
margin-right: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.guide-row {
|
||||
display: flex;
|
||||
@@ -202,6 +198,6 @@ defineProps({
|
||||
position: relative;
|
||||
}
|
||||
.guide-row > image {
|
||||
width: 18%;
|
||||
width: 140rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,6 +27,14 @@ defineProps({
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getMemberNicknameClass = (player = {}) => [
|
||||
"member-nickname",
|
||||
player.vip === true && player.sVip !== true ? "member-nickname--vip" : "",
|
||||
player.sVip === true ? "member-nickname--svip" : "",
|
||||
];
|
||||
|
||||
const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,7 +59,16 @@ defineProps({
|
||||
}"
|
||||
>
|
||||
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
||||
<text class="player-name">{{ player.name }}</text>
|
||||
<view
|
||||
v-if="isMember(player)"
|
||||
:class="['player-name', ...getMemberNicknameClass(player)]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ player.name }}</text>
|
||||
<text v-if="player.sVip === true" class="member-nickname__shine">
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="player-name">{{ player.name }}</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="winner === 1"
|
||||
@@ -70,38 +87,63 @@ defineProps({
|
||||
}"
|
||||
>
|
||||
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
||||
<text class="player-name">{{ player.name }}</text>
|
||||
<view
|
||||
v-if="isMember(player)"
|
||||
:class="['player-name', ...getMemberNicknameClass(player)]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ player.name }}</text>
|
||||
<text v-if="player.sVip === true" class="member-nickname__shine">
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="player-name">{{ player.name }}</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="winner === 0"
|
||||
v-if="winner === 2"
|
||||
src="../static/winner-badge.png"
|
||||
mode="widthFix"
|
||||
class="right-winner-badge"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
<!-- 大乱斗玩家列表:scroll-view 作为横向滚动容器 -->
|
||||
<!-- 小程序中 scroll-view 不支持直接 display:flex,需内部 wrapper view 承载 flex 布局 -->
|
||||
<!-- 仅当玩家 >5 人(内容溢出宽度)时才阻止冒泡,防止与外层 swiper 切换 tab 的手势冲突 -->
|
||||
<scroll-view
|
||||
v-if="players.length"
|
||||
class="players-melee"
|
||||
scroll-x
|
||||
@touchmove="(e) => players.length > 5 && e.stopPropagation()"
|
||||
:style="{ paddingTop: showHeader ? '15px' : '0' }"
|
||||
>
|
||||
<view
|
||||
v-for="(player, index) in players"
|
||||
:key="index"
|
||||
:style="{
|
||||
backgroundColor: meleeAvatarColors[index],
|
||||
width: `${Math.max(100 / players.length, 18)}vw`,
|
||||
}"
|
||||
>
|
||||
<Avatar
|
||||
:src="player.avatar"
|
||||
:rankLvl="showRank ? undefined : player.rankLvl"
|
||||
:size="40"
|
||||
:rank="showRank ? index + 1 : 0"
|
||||
/>
|
||||
<text class="player-name">{{ player.name }}</text>
|
||||
<view class="players-melee-inner">
|
||||
<view
|
||||
v-for="(player, index) in players"
|
||||
:key="index"
|
||||
:style="{
|
||||
backgroundColor: meleeAvatarColors[index],
|
||||
width: `${Math.max(100 / players.length, 18)}vw`,
|
||||
}"
|
||||
>
|
||||
<Avatar
|
||||
:src="player.avatar"
|
||||
:rankLvl="showRank ? undefined : player.rankLvl"
|
||||
:size="40"
|
||||
:rank="showRank ? index + 1 : 0"
|
||||
/>
|
||||
<view
|
||||
v-if="isMember(player)"
|
||||
:class="['player-name', ...getMemberNicknameClass(player)]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ player.name }}</text>
|
||||
<text v-if="player.sVip === true" class="member-nickname__shine">
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="player-name">{{ player.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -144,17 +186,21 @@ defineProps({
|
||||
justify-content: center;
|
||||
}
|
||||
.players-melee {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.players-melee::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
.players-melee > view {
|
||||
/* 小程序 scroll-view 不支持直接 flex,通过内层 wrapper 承载横向排列 */
|
||||
.players-melee-inner {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.players-melee-inner > view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -172,6 +218,13 @@ defineProps({
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
view.player-name {
|
||||
justify-content: center;
|
||||
}
|
||||
.player-name .member-nickname__text,
|
||||
.player-name .member-nickname__shine {
|
||||
font-size: 12px;
|
||||
}
|
||||
.left-winner-badge {
|
||||
position: absolute;
|
||||
width: 50px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
@@ -8,6 +9,9 @@ import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && user.value.sVip !== true);
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -21,6 +25,10 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -31,7 +39,21 @@ const props = defineProps({
|
||||
<view>
|
||||
<Avatar :src="user.avatar" :rankLvl="user.rankLvl" :size="45" />
|
||||
<view>
|
||||
<text>{{ user.nickName }}</text>
|
||||
<view
|
||||
v-if="isVip || isSVip"
|
||||
:class="[
|
||||
'bow-data-user-name',
|
||||
'member-nickname',
|
||||
isVip ? 'member-nickname--vip' : '',
|
||||
isSVip ? 'member-nickname--svip' : '',
|
||||
]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||
<text v-if="isSVip" class="member-nickname__shine">
|
||||
{{ user.nickName }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else>{{ user.nickName }}</text>
|
||||
<text>{{ user.lvlName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -40,21 +62,21 @@ const props = defineProps({
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ width: '100%', marginBottom: '20px' }">
|
||||
<BowTarget :scores="arrows" />
|
||||
<BowTarget :scores="arrows" :isSvip="isSVip" />
|
||||
</view>
|
||||
<view class="desc">
|
||||
<text>{{ arrows.length }}</text>
|
||||
<text>支箭,共</text>
|
||||
<text>{{ arrows.reduce((a, b) => a + b.ring, 0) }}</text>
|
||||
<text>{{ arrows.reduce((a, b) => a + (b.ring || 0), 0) }}</text>
|
||||
<text>环</text>
|
||||
</view>
|
||||
<ScorePanel
|
||||
:completeEffect="false"
|
||||
:rowCount="arrows.length === 12 ? 6 : 9"
|
||||
:total="arrows.length"
|
||||
:scores="arrows.map((a) => a.ring)"
|
||||
:margin="arrows.length === 12 ? 4 : 1"
|
||||
:fontSize="arrows.length === 12 ? 25 : 22"
|
||||
:rowCount="total === 12 ? 6 : 9"
|
||||
:total="total"
|
||||
:arrows="arrows"
|
||||
:margin="total === 12 ? 4 : 1"
|
||||
:fontSize="total === 12 ? 25 : 22"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
@@ -91,6 +113,13 @@ const props = defineProps({
|
||||
margin-left: 10px;
|
||||
color: #fff;
|
||||
}
|
||||
.bow-data-user-name {
|
||||
max-width: 300rpx;
|
||||
}
|
||||
.bow-data-user-name .member-nickname__text,
|
||||
.bow-data-user-name .member-nickname__shine {
|
||||
max-width: 300rpx;
|
||||
}
|
||||
.header > view:first-child > view:last-child > text:last-child {
|
||||
font-size: 10px;
|
||||
background-color: #5f51ff;
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
power: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { getDeviceBatteryAPI } from "@/apis";
|
||||
|
||||
const power = ref(0);
|
||||
const timer = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
power.value = data.battery;
|
||||
timer.value = setInterval(async () => {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
power.value = data.battery;
|
||||
}, 1000 * 10);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container" :style="{ opacity: power > 0 ? 1 : 0 }">
|
||||
<view class="container">
|
||||
<image src="../static/b-power.png" mode="widthFix" />
|
||||
<view>电量{{ power }}%</view>
|
||||
<view>电量{{ power || 1 }}%</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
387
src/components/BowShotEffect.vue
Normal file
387
src/components/BowShotEffect.vue
Normal file
@@ -0,0 +1,387 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
shot: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
playKey: {
|
||||
type: [String, Number],
|
||||
default: "",
|
||||
},
|
||||
targetRadius: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
targetWidth: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
targetHeight: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
hitOffsetPx: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["complete", "impact"]);
|
||||
|
||||
const phase = ref("idle");
|
||||
const activePlayKey = ref("");
|
||||
const animationKey = ref("");
|
||||
const impactEmitted = ref(false);
|
||||
let timers = [];
|
||||
|
||||
const isActive = computed(() => phase.value !== "idle");
|
||||
const ARROW_IMPACT_MS = 340;
|
||||
const COMPLETE_FALLBACK_MS = 980;
|
||||
|
||||
const safeTargetRadius = computed(() => {
|
||||
const radius = Number(props.targetRadius);
|
||||
return Number.isFinite(radius) && radius > 0 ? radius : 20;
|
||||
});
|
||||
|
||||
const safeTargetSize = computed(() => {
|
||||
const width = Number(props.targetWidth);
|
||||
const height = Number(props.targetHeight);
|
||||
return {
|
||||
width: Number.isFinite(width) && width > 0 ? width : 0,
|
||||
height: Number.isFinite(height) && height > 0 ? height : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const shotPoint = computed(() => {
|
||||
const x = Number(props.shot?.x);
|
||||
const y = Number(props.shot?.y);
|
||||
return {
|
||||
x: Number.isFinite(x) ? x : 0,
|
||||
y: Number.isFinite(y) ? y : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const pointDirection = computed(() => {
|
||||
const point = shotPoint.value;
|
||||
const distance = Math.sqrt(point.x * point.x + point.y * point.y);
|
||||
if (distance === 0) return null;
|
||||
|
||||
return {
|
||||
x: point.x / distance,
|
||||
y: point.y / distance,
|
||||
};
|
||||
});
|
||||
|
||||
const hitOffset = computed(() => {
|
||||
const offset = Number(props.hitOffsetPx);
|
||||
const safeOffset = Number.isFinite(offset) && offset > 0 ? offset : 0;
|
||||
const direction = pointDirection.value;
|
||||
|
||||
return {
|
||||
x: direction ? direction.x * safeOffset : 0,
|
||||
y: direction ? -direction.y * safeOffset : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const hitPercent = computed(() => {
|
||||
const point = shotPoint.value;
|
||||
const radius = safeTargetRadius.value;
|
||||
const diameter = radius * 2;
|
||||
|
||||
return {
|
||||
left: ((point.x + radius) / diameter) * 100,
|
||||
top: ((radius - point.y) / diameter) * 100,
|
||||
};
|
||||
});
|
||||
|
||||
const arrowAngle = computed(() => {
|
||||
const size = safeTargetSize.value;
|
||||
if (!size.width || !size.height) {
|
||||
const dx = hitPercent.value.left - 50;
|
||||
const dy = 114 - hitPercent.value.top;
|
||||
const fallbackAngle = Math.atan2(dx, dy || 1) * (180 / Math.PI);
|
||||
return Math.max(-18, Math.min(18, fallbackAngle));
|
||||
}
|
||||
|
||||
const startX = size.width * 0.5;
|
||||
const startY = size.height * 1.14;
|
||||
const endX = size.width * (hitPercent.value.left / 100) + hitOffset.value.x;
|
||||
const endY = size.height * (hitPercent.value.top / 100) + hitOffset.value.y;
|
||||
const dx = endX - startX;
|
||||
const dy = startY - endY;
|
||||
const angle = Math.atan2(dx, dy || 1) * (180 / Math.PI);
|
||||
return Math.max(-18, Math.min(18, angle));
|
||||
});
|
||||
|
||||
function formatPxOffset(value) {
|
||||
if (!value) return "";
|
||||
const operator = value > 0 ? "+" : "-";
|
||||
return ` ${operator} ${Math.abs(value)}px`;
|
||||
}
|
||||
|
||||
function formatTargetPosition(percent, offset) {
|
||||
const pxOffset = formatPxOffset(offset);
|
||||
return pxOffset ? `calc(${percent}%${pxOffset})` : `${percent}%`;
|
||||
}
|
||||
|
||||
const crackStyle = computed(() => ({
|
||||
left: formatTargetPosition(hitPercent.value.left, hitOffset.value.x),
|
||||
top: formatTargetPosition(hitPercent.value.top, hitOffset.value.y),
|
||||
}));
|
||||
|
||||
function getTargetTranslate(percent) {
|
||||
const absPercent = Math.abs(percent);
|
||||
const operator = percent >= 0 ? "-" : "+";
|
||||
return `calc(${percent}vw ${operator} ${absPercent * 0.5}px)`;
|
||||
}
|
||||
|
||||
const arrowMoveStyle = computed(() => {
|
||||
const size = safeTargetSize.value;
|
||||
let x = getTargetTranslate(hitPercent.value.left - 50);
|
||||
let y = getTargetTranslate(hitPercent.value.top - 114);
|
||||
|
||||
if (size.width && size.height) {
|
||||
const startX = size.width * 0.5;
|
||||
const startY = size.height * 1.14;
|
||||
const endX = size.width * (hitPercent.value.left / 100) + hitOffset.value.x;
|
||||
const endY = size.height * (hitPercent.value.top / 100) + hitOffset.value.y;
|
||||
x = `${endX - startX}px`;
|
||||
y = `${endY - startY}px`;
|
||||
}
|
||||
|
||||
return {
|
||||
"--shot-tx": x,
|
||||
"--shot-ty": y,
|
||||
"--shot-angle": `${arrowAngle.value}deg`,
|
||||
};
|
||||
});
|
||||
|
||||
function clearTimers() {
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
timers = [];
|
||||
}
|
||||
|
||||
function queueTimer(callback, delay) {
|
||||
const timer = setTimeout(callback, delay);
|
||||
timers.push(timer);
|
||||
}
|
||||
|
||||
function emitImpactOnce(playKey) {
|
||||
if (phase.value === "idle" || activePlayKey.value !== playKey || impactEmitted.value) return;
|
||||
impactEmitted.value = true;
|
||||
emit("impact");
|
||||
}
|
||||
|
||||
function finish(playKey) {
|
||||
if (phase.value === "idle" || activePlayKey.value !== playKey) return;
|
||||
clearTimers();
|
||||
phase.value = "idle";
|
||||
activePlayKey.value = "";
|
||||
emit("complete", playKey);
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (!props.playKey || !props.shot || !props.shot.ring) return;
|
||||
|
||||
clearTimers();
|
||||
activePlayKey.value = props.playKey;
|
||||
animationKey.value = `${props.playKey}`;
|
||||
impactEmitted.value = false;
|
||||
phase.value = "playing";
|
||||
|
||||
queueTimer(() => {
|
||||
emitImpactOnce(activePlayKey.value);
|
||||
}, ARROW_IMPACT_MS);
|
||||
queueTimer(() => {
|
||||
finish(activePlayKey.value);
|
||||
}, COMPLETE_FALLBACK_MS);
|
||||
}
|
||||
|
||||
function handleArrowAnimationEnd() {
|
||||
emitImpactOnce(activePlayKey.value);
|
||||
}
|
||||
|
||||
function handleCrackAnimationEnd() {
|
||||
finish(activePlayKey.value);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.playKey,
|
||||
() => {
|
||||
play();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearTimers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
v-show="isActive"
|
||||
:class="['shot-effect', `shot-effect--${phase}`]"
|
||||
:style="arrowMoveStyle"
|
||||
>
|
||||
<view
|
||||
:key="`arrow-${animationKey}`"
|
||||
class="shot-arrow-track"
|
||||
@animationend="handleArrowAnimationEnd"
|
||||
>
|
||||
<image
|
||||
class="shot-arrow"
|
||||
src="../static/vip/svip-jian.png"
|
||||
mode="heightFix"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
:key="`flash-${animationKey}`"
|
||||
class="shot-flash"
|
||||
:style="crackStyle"
|
||||
></view>
|
||||
<view
|
||||
:key="`crack-anchor-${animationKey}`"
|
||||
class="shot-crack-anchor"
|
||||
:style="crackStyle"
|
||||
>
|
||||
<image
|
||||
:key="`crack-${animationKey}`"
|
||||
class="shot-crack"
|
||||
src="../static/vip/svip-lie.png"
|
||||
mode="aspectFit"
|
||||
@animationend="handleCrackAnimationEnd"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.shot-effect {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.shot-arrow-track {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 114%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
animation: none;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.shot-arrow {
|
||||
position: absolute;
|
||||
width: 248rpx;
|
||||
height: 1186rpx;
|
||||
left: 0;
|
||||
top: 0;
|
||||
opacity: 1;
|
||||
transform-origin: 44.35% 3.04%;
|
||||
transform: translate(-44.35%, -3.04%) rotate(var(--shot-angle));
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.shot-effect--playing .shot-arrow-track {
|
||||
animation: shot-arrow-fly 0.38s cubic-bezier(0.68, 0, 0.9, 0.62) forwards;
|
||||
}
|
||||
|
||||
.shot-flash,
|
||||
.shot-crack-anchor {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
backface-visibility: hidden;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.shot-flash {
|
||||
width: 86rpx;
|
||||
height: 86rpx;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid rgba(255, 236, 166, 0.9);
|
||||
opacity: 0;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.shot-crack-anchor {
|
||||
width: 750rpx;
|
||||
height: 750rpx;
|
||||
}
|
||||
|
||||
.shot-crack {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transform-origin: center center;
|
||||
animation: none;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.shot-effect--playing .shot-flash {
|
||||
animation: shot-flash 0.42s ease-out 0.32s forwards;
|
||||
}
|
||||
|
||||
.shot-effect--playing .shot-crack {
|
||||
animation: shot-crack-hit 0.52s ease-out 0.34s forwards;
|
||||
}
|
||||
|
||||
@keyframes shot-arrow-fly {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
86% {
|
||||
opacity: 1;
|
||||
transform: translate3d(var(--shot-tx), var(--shot-ty), 0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(var(--shot-tx), var(--shot-ty), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shot-flash {
|
||||
0% {
|
||||
opacity: 0.95;
|
||||
transform: translate(-50%, -50%) scale(0.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shot-crack-hit {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.55);
|
||||
}
|
||||
28% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
56% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import BowPower from "@/components/BowPower.vue";
|
||||
import StartCountdown from "@/components/StartCountdown.vue";
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed, nextTick } from "vue";
|
||||
import PointSwitcher from "@/components/PointSwitcher.vue";
|
||||
import BowShotEffect from "@/components/BowShotEffect.vue";
|
||||
|
||||
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -17,14 +19,6 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
avatar: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
power: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
scores: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
@@ -33,36 +27,155 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isSvip: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "solo", // solo 单排,team 双排
|
||||
},
|
||||
// start: {
|
||||
// type: Boolean,
|
||||
// default: false,
|
||||
// },
|
||||
stop: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
targetRadius: {
|
||||
type: Number,
|
||||
default: 20,
|
||||
},
|
||||
hitRadiusPx: {
|
||||
type: Number,
|
||||
default: 2,
|
||||
},
|
||||
zoomHitRadiusPx: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
});
|
||||
|
||||
const showsimul = ref(false);
|
||||
const pMode = ref(true);
|
||||
const latestOne = ref(null);
|
||||
const bluelatestOne = ref(null);
|
||||
const prevScores = ref([]);
|
||||
const prevBlueScores = ref([]);
|
||||
const timer = ref(null);
|
||||
const dirTimer = ref(null);
|
||||
const angle = ref(null);
|
||||
const circleColor = ref("");
|
||||
const shotEffect = ref(null);
|
||||
const hiddenRedLatestKey = ref("");
|
||||
const hiddenBlueLatestKey = ref("");
|
||||
const targetShaking = ref(false);
|
||||
const shakeTimer = ref(null);
|
||||
const ROUND_TIP_OFFSET_Y = -32;
|
||||
const EXPERIENCE_TIP_OFFSET_Y = -68;
|
||||
|
||||
function buildShotEffectKey(team, shot, index) {
|
||||
return [
|
||||
team,
|
||||
index,
|
||||
shot?.playerId ?? "",
|
||||
shot?.x ?? "",
|
||||
shot?.y ?? "",
|
||||
shot?.ring ?? "",
|
||||
shot?.ringX ? 1 : 0,
|
||||
].join("-");
|
||||
}
|
||||
|
||||
function shouldPlayShotEffect(shot) {
|
||||
return props.isSvip && !!shot && Number(shot.ring) > 0;
|
||||
}
|
||||
|
||||
function clearTipTimer() {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showShotTip(team, shot) {
|
||||
clearTipTimer();
|
||||
if (team === "red") {
|
||||
latestOne.value = shot;
|
||||
timer.value = setTimeout(() => {
|
||||
latestOne.value = null;
|
||||
timer.value = null;
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
bluelatestOne.value = shot;
|
||||
timer.value = setTimeout(() => {
|
||||
bluelatestOne.value = null;
|
||||
timer.value = null;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function triggerShotEffect(team, shot, index) {
|
||||
const key = buildShotEffectKey(team, shot, index);
|
||||
|
||||
if (shotEffect.value?.team === "red") hiddenRedLatestKey.value = "";
|
||||
if (shotEffect.value?.team === "blue") hiddenBlueLatestKey.value = "";
|
||||
|
||||
if (team === "red") {
|
||||
latestOne.value = null;
|
||||
hiddenRedLatestKey.value = key;
|
||||
} else {
|
||||
bluelatestOne.value = null;
|
||||
hiddenBlueLatestKey.value = key;
|
||||
}
|
||||
|
||||
shotEffect.value = { key, team, shot };
|
||||
}
|
||||
|
||||
function completeShotEffect(key) {
|
||||
if (!shotEffect.value || shotEffect.value.key !== key) return;
|
||||
|
||||
const { team, shot } = shotEffect.value;
|
||||
if (team === "red") hiddenRedLatestKey.value = "";
|
||||
if (team === "blue") hiddenBlueLatestKey.value = "";
|
||||
shotEffect.value = null;
|
||||
showShotTip(team, shot);
|
||||
}
|
||||
|
||||
function shakeTarget() {
|
||||
targetShaking.value = false;
|
||||
if (shakeTimer.value) {
|
||||
clearTimeout(shakeTimer.value);
|
||||
shakeTimer.value = null;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
targetShaking.value = true;
|
||||
shakeTimer.value = setTimeout(() => {
|
||||
targetShaking.value = false;
|
||||
shakeTimer.value = null;
|
||||
}, 260);
|
||||
});
|
||||
}
|
||||
|
||||
function shouldHideRedHit(index) {
|
||||
return !!hiddenRedLatestKey.value && index === props.scores.length - 1;
|
||||
}
|
||||
|
||||
function shouldHideBlueHit(index) {
|
||||
return !!hiddenBlueLatestKey.value && index === props.blueScores.length - 1;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.scores,
|
||||
(newVal) => {
|
||||
if (newVal.length - prevScores.value.length === 1) {
|
||||
latestOne.value = newVal[newVal.length - 1];
|
||||
if (timer.value) clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
latestOne.value = null;
|
||||
}, 1000);
|
||||
const latestShot = newVal[newVal.length - 1];
|
||||
if (shouldPlayShotEffect(latestShot)) {
|
||||
triggerShotEffect("red", latestShot, newVal.length - 1);
|
||||
} else {
|
||||
showShotTip("red", latestShot);
|
||||
}
|
||||
} else if (newVal.length <= prevScores.value.length) {
|
||||
latestOne.value = null;
|
||||
hiddenRedLatestKey.value = "";
|
||||
if (shotEffect.value?.team === "red") shotEffect.value = null;
|
||||
}
|
||||
prevScores.value = [...newVal];
|
||||
},
|
||||
@@ -75,11 +188,16 @@ watch(
|
||||
() => props.blueScores,
|
||||
(newVal) => {
|
||||
if (newVal.length - prevBlueScores.value.length === 1) {
|
||||
bluelatestOne.value = newVal[newVal.length - 1];
|
||||
if (timer.value) clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
bluelatestOne.value = null;
|
||||
}, 1000);
|
||||
const latestShot = newVal[newVal.length - 1];
|
||||
if (shouldPlayShotEffect(latestShot)) {
|
||||
triggerShotEffect("blue", latestShot, newVal.length - 1);
|
||||
} else {
|
||||
showShotTip("blue", latestShot);
|
||||
}
|
||||
} else if (newVal.length <= prevBlueScores.value.length) {
|
||||
bluelatestOne.value = null;
|
||||
hiddenBlueLatestKey.value = "";
|
||||
if (shotEffect.value?.team === "blue") shotEffect.value = null;
|
||||
}
|
||||
prevBlueScores.value = [...newVal];
|
||||
},
|
||||
@@ -88,108 +206,276 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
function calcRealX(num, offset = 3.4) {
|
||||
const len = 20.4 + num;
|
||||
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
||||
const safeTargetRadius = computed(() => {
|
||||
const radius = Number(props.targetRadius);
|
||||
return Number.isFinite(radius) && radius > 0 ? radius : 20;
|
||||
});
|
||||
|
||||
const currentHitRadiusPx = computed(() => {
|
||||
const radius = Number(
|
||||
pMode.value ? props.zoomHitRadiusPx : props.hitRadiusPx
|
||||
);
|
||||
return Number.isFinite(radius) && radius >= 0 ? radius : 0;
|
||||
});
|
||||
|
||||
function getShotPoint(shot, fallbackCenter = false) {
|
||||
const x = Number(shot?.x);
|
||||
const y = Number(shot?.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
|
||||
return fallbackCenter ? { x: 0, y: 0 } : null;
|
||||
}
|
||||
function calcRealY(num, offset = 3.4) {
|
||||
const len = num < 0 ? Math.abs(num) + 20.4 : 20.4 - num;
|
||||
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
||||
|
||||
function getPointDirection(point) {
|
||||
if (!point) return null;
|
||||
const distance = Math.sqrt(point.x * point.x + point.y * point.y);
|
||||
if (distance === 0) return null;
|
||||
|
||||
return {
|
||||
x: point.x / distance,
|
||||
y: point.y / distance,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPxOffset(value) {
|
||||
if (!value) return "";
|
||||
const operator = value > 0 ? "+" : "-";
|
||||
return ` ${operator} ${Math.abs(value)}px`;
|
||||
}
|
||||
|
||||
function formatTargetPosition(percent, offset) {
|
||||
const pxOffset = formatPxOffset(offset);
|
||||
return pxOffset ? `calc(${percent}%${pxOffset})` : `${percent}%`;
|
||||
}
|
||||
|
||||
function getTargetPositionStyle(point, offsetPx = 0, extraOffset = {}) {
|
||||
if (!point) return { display: "none" };
|
||||
|
||||
const radius = safeTargetRadius.value;
|
||||
const diameter = radius * 2;
|
||||
const direction = getPointDirection(point);
|
||||
const xOffset = (direction ? direction.x * offsetPx : 0) + (extraOffset.x || 0);
|
||||
const yOffset = (direction ? -direction.y * offsetPx : 0) + (extraOffset.y || 0);
|
||||
const leftPercent = ((point.x + radius) / diameter) * 100;
|
||||
const topPercent = ((radius - point.y) / diameter) * 100;
|
||||
|
||||
return {
|
||||
left: formatTargetPosition(leftPercent, xOffset),
|
||||
top: formatTargetPosition(topPercent, yOffset),
|
||||
transform: "translate(-50%, -50%)",
|
||||
};
|
||||
}
|
||||
|
||||
function getHitStyle(shot) {
|
||||
const radius = currentHitRadiusPx.value;
|
||||
const point = getShotPoint(shot);
|
||||
|
||||
return {
|
||||
...getTargetPositionStyle(point, radius),
|
||||
width: `${radius * 2}px`,
|
||||
height: `${radius * 2}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function getSvipHitBgStyle(shot) {
|
||||
const radius = currentHitRadiusPx.value;
|
||||
const point = getShotPoint(shot);
|
||||
|
||||
return {
|
||||
...getTargetPositionStyle(point, radius),
|
||||
};
|
||||
}
|
||||
|
||||
function getRoundTipStyle(shot) {
|
||||
const point = getShotPoint(shot, true);
|
||||
return getTargetPositionStyle(
|
||||
point,
|
||||
shot?.ring ? currentHitRadiusPx.value : 0,
|
||||
{ y: ROUND_TIP_OFFSET_Y }
|
||||
);
|
||||
}
|
||||
|
||||
function getExperienceTipStyle(shot) {
|
||||
const point = getShotPoint(shot, true);
|
||||
return getTargetPositionStyle(
|
||||
point,
|
||||
shot?.ring ? currentHitRadiusPx.value : 0,
|
||||
{ y: EXPERIENCE_TIP_OFFSET_Y }
|
||||
);
|
||||
}
|
||||
const simulShoot = async () => {
|
||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||
};
|
||||
const simulShoot2 = async () => {
|
||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId, 1, 1);
|
||||
if (device.value.deviceId) {
|
||||
const r1 = Math.random() > 0.5 ? 0.01 : 0.02;
|
||||
await simulShootAPI(device.value.deviceId, r1, r1);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const env = computed(() => {
|
||||
const accountInfo = uni.getAccountInfoSync();
|
||||
const envVersion = accountInfo.miniProgram.envVersion;
|
||||
if (envVersion !== "release") showsimul.value = true;
|
||||
return accountInfo.miniProgram.envVersion;
|
||||
});
|
||||
|
||||
const arrowStyle = computed(() => {
|
||||
return {
|
||||
transform: `rotateX(180deg) translate(-50%, -50%) rotate(${
|
||||
360 - angle.value
|
||||
}deg) translateY(105%)`,
|
||||
};
|
||||
});
|
||||
|
||||
async function onReceiveMessage(message) {
|
||||
if (Array.isArray(message)) return;
|
||||
if (message.type === MESSAGETYPESV2.ShootResult && message.shootData) {
|
||||
if (
|
||||
message.shootData.playerId === user.value.id &&
|
||||
!message.shootData.ring &&
|
||||
message.shootData.angle >= 0
|
||||
) {
|
||||
angle.value = null;
|
||||
setTimeout(() => {
|
||||
if (props.scores[0]) {
|
||||
circleColor.value =
|
||||
message.shootData.playerId === props.scores[0].playerId
|
||||
? "#ff4444"
|
||||
: "#1840FF";
|
||||
}
|
||||
angle.value = message.shootData.angle;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
if (dirTimer.value) {
|
||||
clearTimeout(dirTimer.value);
|
||||
dirTimer.value = null;
|
||||
}
|
||||
if (shakeTimer.value) {
|
||||
clearTimeout(shakeTimer.value);
|
||||
shakeTimer.value = null;
|
||||
}
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="header" v-if="totalRound > 0 || power">
|
||||
<view :class="['container', { 'container--effecting': shotEffect }]">
|
||||
<view class="header" v-if="totalRound > 0">
|
||||
<text v-if="totalRound > 0" class="round-count">{{
|
||||
(currentRound > totalRound ? totalRound : currentRound) +
|
||||
"/" +
|
||||
totalRound
|
||||
}}</text>
|
||||
<BowPower :power="power" />
|
||||
</view>
|
||||
<view class="target">
|
||||
<view :class="['target', { 'target--shake': targetShaking }]">
|
||||
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
||||
<view :style="{ background: circleColor }">
|
||||
<image src="../static/dot-circle.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="stop" class="stop-sign">中场休息</view>
|
||||
<view
|
||||
v-if="latestOne && user.id === latestOne.playerId"
|
||||
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
||||
class="e-value fade-in-out"
|
||||
:style="{
|
||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 20),
|
||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 40),
|
||||
}"
|
||||
:style="getExperienceTipStyle(latestOne)"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
<view
|
||||
v-if="latestOne"
|
||||
class="round-tip fade-in-out"
|
||||
:style="{
|
||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 28),
|
||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 28),
|
||||
}"
|
||||
>{{ latestOne.ring || "未上靶"
|
||||
}}<text v-if="latestOne.ring">环</text></view
|
||||
:style="getRoundTipStyle(latestOne)"
|
||||
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
|
||||
}}<text v-if="latestOne.ring">环</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="
|
||||
bluelatestOne &&
|
||||
bluelatestOne.ring &&
|
||||
user.id === bluelatestOne.playerId
|
||||
"
|
||||
class="e-value fade-in-out"
|
||||
:style="getExperienceTipStyle(bluelatestOne)"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
<view
|
||||
v-if="bluelatestOne"
|
||||
class="round-tip fade-in-out"
|
||||
:style="getRoundTipStyle(bluelatestOne)"
|
||||
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
|
||||
}}<text v-if="bluelatestOne.ring">环</text></view
|
||||
>
|
||||
<block v-for="(bow, index) in scores" :key="index">
|
||||
<image
|
||||
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideRedHit(index)"
|
||||
class="svip-hit-bg"
|
||||
src="../static/vip/svip-xuan.png"
|
||||
:style="getSvipHitBgStyle(bow)"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view
|
||||
v-if="bow.ring > 0"
|
||||
:class="`hit ${
|
||||
v-if="bow.ring > 0 && !shouldHideRedHit(index)"
|
||||
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
||||
}`"
|
||||
:style="{
|
||||
left: calcRealX(bow.x),
|
||||
top: calcRealY(bow.y),
|
||||
backgroundColor:
|
||||
index === scores.length - 1 &&
|
||||
!blueScores.length &&
|
||||
latestOne &&
|
||||
mode !== 'team'
|
||||
? 'green'
|
||||
: '#ff4444',
|
||||
...getHitStyle(bow),
|
||||
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
|
||||
}"
|
||||
><text>{{ index + 1 }}</text></view
|
||||
><text v-if="pMode">{{ index + 1 }}</text></view
|
||||
>
|
||||
</block>
|
||||
<block v-for="(bow, index) in blueScores" :key="index">
|
||||
<image
|
||||
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideBlueHit(index)"
|
||||
class="svip-hit-bg"
|
||||
src="../static/vip/svip-xuan.png"
|
||||
:style="getSvipHitBgStyle(bow)"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view
|
||||
v-if="bow.ring > 0"
|
||||
:class="`hit ${
|
||||
v-if="bow.ring > 0 && !shouldHideBlueHit(index)"
|
||||
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
|
||||
}`"
|
||||
:style="{
|
||||
left: calcRealX(bow.x),
|
||||
top: calcRealY(bow.y),
|
||||
backgroundColor: 'blue',
|
||||
...getHitStyle(bow),
|
||||
backgroundColor: '#1840FF',
|
||||
}"
|
||||
>
|
||||
<text>{{ index + 1 }}</text>
|
||||
<text v-if="pMode">{{ index + 1 }}</text>
|
||||
</view>
|
||||
</block>
|
||||
<BowShotEffect
|
||||
:shot="shotEffect && shotEffect.shot"
|
||||
:playKey="shotEffect ? shotEffect.key : ''"
|
||||
:targetRadius="safeTargetRadius"
|
||||
@impact="shakeTarget"
|
||||
@complete="completeShotEffect"
|
||||
/>
|
||||
<image src="../static/bow-target.png" mode="widthFix" />
|
||||
</view>
|
||||
<view v-if="avatar" class="footer">
|
||||
<image :src="avatar" mode="widthFix" />
|
||||
<view class="footer">
|
||||
<PointSwitcher
|
||||
:onChange="(val) => (pMode = val)"
|
||||
:style="{ zIndex: 999 }"
|
||||
/>
|
||||
</view>
|
||||
<view class="simul" v-if="showsimul">
|
||||
<view class="simul" v-if="env !== 'release'">
|
||||
<button @click="simulShoot">模拟</button>
|
||||
<button @click="simulShoot2">射箭</button>
|
||||
</view>
|
||||
<!-- <text :style="{ color: '#fff', wordBreak: 'break-all' }">{{
|
||||
scores.length ? scores[scores.length - 1] : ""
|
||||
}}</text> -->
|
||||
<!-- <StartCountdown :start="startCount" /> -->
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -199,17 +485,25 @@ onMounted(() => {
|
||||
height: calc(100vw - 30px);
|
||||
padding: 0px 15px;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.container--effecting {
|
||||
z-index: 10000;
|
||||
}
|
||||
.target {
|
||||
position: relative;
|
||||
margin: 10px;
|
||||
width: calc(100% - 20px);
|
||||
height: calc(100% - 20px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.target--shake {
|
||||
animation: target-shake 0.26s ease-out;
|
||||
}
|
||||
.e-value {
|
||||
position: absolute;
|
||||
/* top: 30%;
|
||||
left: 60%; */
|
||||
background-color: #0006;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
@@ -221,8 +515,6 @@ onMounted(() => {
|
||||
}
|
||||
.round-tip {
|
||||
position: absolute;
|
||||
/* top: 38%; */
|
||||
/* left: 60%; */
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
@@ -234,34 +526,119 @@ onMounted(() => {
|
||||
font-size: 24px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
@keyframes target-tip-fade-in-out {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.round-tip.fade-in-out,
|
||||
.e-value.fade-in-out {
|
||||
animation: target-tip-fade-in-out 1.2s ease forwards;
|
||||
}
|
||||
.target > image:last-child {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.svip-hit-bg {
|
||||
position: absolute;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
animation: svip-hit-xuan 1.2s linear infinite;
|
||||
}
|
||||
.hit {
|
||||
position: absolute;
|
||||
width: 3.4%;
|
||||
height: 3.4%;
|
||||
min-width: 3.4%;
|
||||
min-height: 3.4%;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #fff;
|
||||
z-index: 1;
|
||||
z-index: 2;
|
||||
color: #fff;
|
||||
font-size: 2.1vw;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.b-point {
|
||||
border: 1px solid #fff;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
/* transform: translate(-50%, -50%); */
|
||||
}
|
||||
.hit > text {
|
||||
transform: scaleX(0.7) translateY(-0.5px);
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
.b-point > text {
|
||||
font-size: 16rpx;
|
||||
color: #fff;
|
||||
font-family: "DINCondensed";
|
||||
/* text-align: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);*/
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
@keyframes svip-hit-xuan {
|
||||
0% {
|
||||
opacity: 0.9;
|
||||
transform: translate(-50%, -50%) rotate(0deg) scale(0.92);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(180deg) scale(1.08);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: translate(-50%, -50%) rotate(360deg) scale(0.92);
|
||||
}
|
||||
}
|
||||
@keyframes target-pump-in {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes target-shake {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
14% {
|
||||
transform: translate(-20rpx, 8rpx);
|
||||
}
|
||||
28% {
|
||||
transform: translate(16rpx, -8rpx);
|
||||
}
|
||||
44% {
|
||||
transform: translate(-12rpx, 6rpx);
|
||||
}
|
||||
64% {
|
||||
transform: translate(8rpx, -4rpx);
|
||||
}
|
||||
82% {
|
||||
transform: translate(-4rpx, 2rpx);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
.hit.pump-in {
|
||||
animation: target-pump-in 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.header {
|
||||
width: 100%;
|
||||
@@ -285,6 +662,7 @@ onMounted(() => {
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
margin-top: -40px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.footer > image {
|
||||
width: 40px;
|
||||
@@ -295,9 +673,10 @@ onMounted(() => {
|
||||
}
|
||||
.simul {
|
||||
position: absolute;
|
||||
bottom: 40px;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
margin-left: 20px;
|
||||
z-index: 999;
|
||||
}
|
||||
.simul > button {
|
||||
color: #fff;
|
||||
@@ -314,4 +693,72 @@ onMounted(() => {
|
||||
z-index: 99;
|
||||
font-weight: bold;
|
||||
}
|
||||
.arrow-dir {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 52%;
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.arrow-dir > view {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.arrow-dir > view > image {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
transform: translate(-30%, -30%);
|
||||
}
|
||||
@keyframes spring-in {
|
||||
0% {
|
||||
transform: scale(2);
|
||||
opacity: 0.4;
|
||||
}
|
||||
15% {
|
||||
transform: scale(3);
|
||||
opacity: 1;
|
||||
}
|
||||
30% {
|
||||
transform: scale(2);
|
||||
opacity: 0.4;
|
||||
}
|
||||
45% {
|
||||
transform: scale(3);
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
transform: scale(2);
|
||||
opacity: 0.4;
|
||||
}
|
||||
75% {
|
||||
transform: scale(3);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes disappear {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
75% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.arrow-dir > view {
|
||||
animation: disappear 3s ease forwards;
|
||||
}
|
||||
.arrow-dir > view > image {
|
||||
animation: spring-in 3s ease forwards;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
111
src/components/BowTarget/HalfRingTarget.vue
Normal file
111
src/components/BowTarget/HalfRingTarget.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
diameter: {
|
||||
type: Number,
|
||||
default: 90,
|
||||
},
|
||||
});
|
||||
|
||||
const side = computed(() => {
|
||||
return props.diameter / 10;
|
||||
});
|
||||
|
||||
const rings = ["X", "10", "9", "8", "7", "6"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="container circle"
|
||||
:style="{
|
||||
width: diameter + 'vw',
|
||||
height: diameter + 'vw',
|
||||
background: '#00BAE9',
|
||||
}"
|
||||
>
|
||||
<view
|
||||
class="circle"
|
||||
:style="{
|
||||
background: '#FF5665',
|
||||
width: side * 8 + 'vw',
|
||||
height: side * 8 + 'vw',
|
||||
}"
|
||||
>
|
||||
<view class="rings" :style="{ transform: `translateX(-${side}vw)` }">
|
||||
<text
|
||||
v-for="(ring, index) in rings"
|
||||
:key="ring"
|
||||
:style="{
|
||||
width: side + 'vw',
|
||||
transform: `translateX(${
|
||||
index === 0 ? side / 2 : index === 1 ? side / 4.5 : 0
|
||||
}vw)`,
|
||||
}"
|
||||
>{{ ring }}</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="circle"
|
||||
:style="{
|
||||
background: '#FF5665',
|
||||
width: side * 6 + 'vw',
|
||||
height: side * 6 + 'vw',
|
||||
}"
|
||||
>
|
||||
<view
|
||||
class="circle"
|
||||
:style="{
|
||||
background: '#FDDC61',
|
||||
width: side * 4 + 'vw',
|
||||
height: side * 4 + 'vw',
|
||||
}"
|
||||
>
|
||||
<view
|
||||
class="circle"
|
||||
:style="{
|
||||
background: '#FDDC61',
|
||||
width: side * 2 + 'vw',
|
||||
height: side * 2 + 'vw',
|
||||
}"
|
||||
>
|
||||
<view
|
||||
class="circle"
|
||||
:style="{
|
||||
background: '#FDDC61',
|
||||
width: side + 'vw',
|
||||
height: side + 'vw',
|
||||
}"
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
position: relative;
|
||||
}
|
||||
.rings {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
left: 50%;
|
||||
}
|
||||
.rings > text {
|
||||
font-size: 24rpx;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
.circle {
|
||||
border: 1rpx solid #3e3e3e66;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { getElementRect, calcRing } from "@/util";
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { getElementRect, calcRing, capsuleHeight } from "@/util";
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -19,18 +19,15 @@ const props = defineProps({
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const rect = ref({});
|
||||
const arrow = ref(null);
|
||||
const isDragging = ref(false);
|
||||
const dragStartPos = ref({ x: 0, y: 0 });
|
||||
const capsuleHeight = ref(0);
|
||||
const scale = ref(1);
|
||||
const scrollTop = ref(0);
|
||||
const selected = ref(null);
|
||||
let lastMoveTime = 0;
|
||||
|
||||
// 点击靶纸创建新的点
|
||||
@@ -43,19 +40,33 @@ const onClick = async (e) => {
|
||||
return;
|
||||
}
|
||||
if (props.id === 7 || props.id === 9) {
|
||||
scale.value = 1.5;
|
||||
if (
|
||||
e.detail.x < rect.value.width * 0.2 ||
|
||||
e.detail.x > rect.value.width * 0.8
|
||||
)
|
||||
return;
|
||||
// 放大并通过滚动将点击位置置于视窗中心
|
||||
scale.value = 1.4;
|
||||
const viewportH = rect.value.width; // 容器高度等于宽度(100vw)
|
||||
const contentH = scale.value * rect.value.width; // 内容高度
|
||||
const clickYInContainer = e.detail.y - rect.value.top;
|
||||
let target = clickYInContainer * scale.value - viewportH / 2;
|
||||
target = Math.max(0, Math.min(contentH - viewportH, target));
|
||||
setTimeout(() => {
|
||||
scrollTop.value = target > 180 ? target + 10 : target;
|
||||
}, 200);
|
||||
}
|
||||
const newArrow = {
|
||||
x: (e.detail.x - 6) * scale.value,
|
||||
y: (e.detail.y - rect.value.top - capsuleHeight.value - 6) * scale.value,
|
||||
y: (e.detail.y - rect.value.top - capsuleHeight - 6) * scale.value,
|
||||
};
|
||||
|
||||
const side = rect.value.width;
|
||||
newArrow.ring = calcRing(
|
||||
props.id,
|
||||
newArrow.x / scale.value - rect.value.width * 0.05,
|
||||
newArrow.y / scale.value - rect.value.width * 0.05,
|
||||
rect.value.width * 0.9
|
||||
newArrow.x / scale.value - side * 0.05,
|
||||
newArrow.y / scale.value - side * 0.05,
|
||||
side * 0.9
|
||||
);
|
||||
arrow.value = {
|
||||
...newArrow,
|
||||
@@ -75,12 +86,14 @@ const confirmAdd = () => {
|
||||
}
|
||||
arrow.value = null;
|
||||
scale.value = 1;
|
||||
scrollTop.value = 0;
|
||||
};
|
||||
|
||||
// 删除箭矢
|
||||
const deleteArrow = () => {
|
||||
arrow.value = null;
|
||||
scale.value = 1;
|
||||
scrollTop.value = 0;
|
||||
};
|
||||
|
||||
// 开始拖拽 - 同样修复坐标获取
|
||||
@@ -115,9 +128,9 @@ const onDrag = async (e) => {
|
||||
);
|
||||
arrow.value.ring = calcRing(
|
||||
props.id,
|
||||
arrow.value.x / scale.value - rect.value.width * 0.05,
|
||||
arrow.value.y / scale.value - rect.value.width * 0.05,
|
||||
rect.value.width * 0.9
|
||||
arrow.value.x / scale.value - side * 0.05,
|
||||
arrow.value.y / scale.value - side * 0.05,
|
||||
side * 0.9
|
||||
);
|
||||
|
||||
arrow.value.x = arrow.value.x / side;
|
||||
@@ -134,27 +147,63 @@ const endDrag = (e) => {
|
||||
|
||||
const getNewPos = () => {
|
||||
if (props.id === 7 || props.id === 9) {
|
||||
if (arrow.value.y > 1.4)
|
||||
if (arrow.value.y >= 1.33)
|
||||
return { left: "-12px", bottom: "calc(50% - 12px)" };
|
||||
} else {
|
||||
if (arrow.value.y > 0.88) {
|
||||
if (arrow.value.x < 0.05) {
|
||||
return { left: "calc(100% - 12px)", bottom: "calc(100% - 12px)" };
|
||||
}
|
||||
return { left: "-12px", bottom: "calc(50% - 12px)" };
|
||||
}
|
||||
}
|
||||
return { left: "calc(50% - 12px)", bottom: "-12px" };
|
||||
};
|
||||
|
||||
const setEditArrow = (data) => {
|
||||
selected.value = data;
|
||||
// if (data === null) {
|
||||
// arrow.value = null;
|
||||
// scale.value = 1;
|
||||
// scrollTop.value = 0;
|
||||
// return;
|
||||
// }
|
||||
// if (props.id === 7 || props.id === 9) {
|
||||
// scale.value = 1.4;
|
||||
// const viewportH = rect.value.width; // 容器高度等于宽度(100vw)
|
||||
// const contentH = scale.value * rect.value.width; // 内容高度
|
||||
// const clickYInContainer = contentH * data.y - rect.value.top;
|
||||
// let target = clickYInContainer * scale.value - viewportH / 2;
|
||||
// target = Math.max(0, Math.min(contentH - viewportH, target));
|
||||
// setTimeout(() => {
|
||||
// scrollTop.value = target > 180 ? target + 10 : target;
|
||||
// }, 200);
|
||||
// }
|
||||
// arrow.value = {
|
||||
// ...data,
|
||||
// x: data.x * scale.value,
|
||||
// y: data.y * scale.value,
|
||||
// };
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const menuBtnInfo = uni.getMenuButtonBoundingClientRect();
|
||||
capsuleHeight.value = menuBtnInfo.top - 9;
|
||||
const result = await getElementRect(".container");
|
||||
rect.value = result;
|
||||
uni.$on("set-edit-arrow", setEditArrow);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("set-edit-arrow", setEditArrow);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
:style="{ overflowY: editMode ? 'auto' : 'hidden' }"
|
||||
<scroll-view
|
||||
:scroll-y="scale > 1"
|
||||
scroll-with-animation
|
||||
:scroll-top="scrollTop"
|
||||
:show-scrollbar="false"
|
||||
:enhanced="true"
|
||||
class="container"
|
||||
@tap="onClick"
|
||||
@touchmove="onDrag"
|
||||
@@ -165,16 +214,16 @@ onMounted(async () => {
|
||||
:style="{
|
||||
width: scale * 100 + 'vw',
|
||||
height: scale * 100 + 'vw',
|
||||
transform: `translate(${(100 - scale * 100) / 2}vw,${
|
||||
(100 - scale * 100) / 2
|
||||
}vw) translateY(${scale > 1 ? 16.7 : 0}%)`,
|
||||
transform: `translateX(${(100 - scale * 100) / 2}vw)`,
|
||||
}"
|
||||
>
|
||||
<image :src="src" mode="widthFix" />
|
||||
<view
|
||||
v-for="(arrow, index) in arrows"
|
||||
:key="index"
|
||||
class="arrow-point"
|
||||
:class="`arrow-point ${
|
||||
selected !== null && index === selected ? 'selected-arrow-point' : ''
|
||||
}`"
|
||||
:style="{
|
||||
left: (arrow.x !== undefined ? arrow.x : 0) * 100 + '%',
|
||||
top: (arrow.y !== undefined ? arrow.y : 0) * 100 + '%',
|
||||
@@ -183,9 +232,6 @@ onMounted(async () => {
|
||||
<view
|
||||
v-if="arrow.x !== undefined && arrow.y !== undefined"
|
||||
class="point"
|
||||
:style="{
|
||||
transform: props.id === 7 || props.id === 9 ? 'scale(0.7)' : '',
|
||||
}"
|
||||
>
|
||||
<text>{{ index + 1 }}</text>
|
||||
</view>
|
||||
@@ -199,36 +245,44 @@ onMounted(async () => {
|
||||
:x="arrow ? rect.width * arrow.x : 0"
|
||||
:y="arrow ? rect.width * arrow.y : 0"
|
||||
>
|
||||
<view class="point"> </view>
|
||||
<view v-if="arrow" class="edit-buttons" @touchstart.stop>
|
||||
<view class="edit-btn-text">
|
||||
<text>{{ arrow.ring === 0 ? "M" : arrow.ring }}</text>
|
||||
<text
|
||||
v-if="arrow.ring > 0"
|
||||
:style="{
|
||||
fontSize: '16px',
|
||||
marginLeft: '2px',
|
||||
}"
|
||||
>环</text
|
||||
<view
|
||||
class="point"
|
||||
:style="{ minWidth: 10 * scale + 'px', minHeight: 10 * scale + 'px' }"
|
||||
>
|
||||
<view v-if="arrow" class="edit-buttons" @touchstart.stop>
|
||||
<view class="edit-btn-text">
|
||||
<text>{{ arrow.ring === 0 ? "M" : arrow.ring }}</text>
|
||||
<text
|
||||
v-if="arrow.ring > 0"
|
||||
:style="{
|
||||
fontSize: '16px',
|
||||
marginLeft: '2px',
|
||||
}"
|
||||
>环</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="edit-btn confirm-btn"
|
||||
@touchstart.stop="confirmAdd"
|
||||
:style="{ ...getNewPos() }"
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
class="edit-btn confirm-btn"
|
||||
@touchstart.stop="confirmAdd"
|
||||
:style="{ ...getNewPos() }"
|
||||
>
|
||||
<image src="../static/arrow-edit-save.png" mode="widthFix" />
|
||||
</view>
|
||||
<view class="edit-btn delete-btn" @touchstart.stop="deleteArrow">
|
||||
<image src="../static/arrow-edit-delete.png" mode="widthFix" />
|
||||
</view>
|
||||
<view class="edit-btn drag-btn" @touchstart.stop="startDrag($event)">
|
||||
<image src="../static/arrow-edit-move.png" mode="widthFix" />
|
||||
<image src="../static/arrow-edit-save.png" mode="widthFix" />
|
||||
</view>
|
||||
<view class="edit-btn delete-btn" @touchstart.stop="deleteArrow">
|
||||
<image src="../static/arrow-edit-delete.png" mode="widthFix" />
|
||||
</view>
|
||||
<view
|
||||
class="edit-btn drag-btn"
|
||||
@touchstart.stop="startDrag($event)"
|
||||
>
|
||||
<image src="../static/arrow-edit-move.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</movable-view>
|
||||
<!-- <view class="test-view"></view> -->
|
||||
</movable-area>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -268,31 +322,35 @@ onMounted(async () => {
|
||||
|
||||
.arrow-point {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.point {
|
||||
min-width: 12px;
|
||||
min-height: 12px;
|
||||
min-width: 10px;
|
||||
min-height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #fff;
|
||||
color: #fff;
|
||||
font-size: 8px;
|
||||
text-align: center;
|
||||
line-height: 10px;
|
||||
box-sizing: border-box;
|
||||
background-color: #ff4444;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
background-color: #00bf04;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
transition: all 0.1s linear;
|
||||
position: relative;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.point > text {
|
||||
transform: scaleX(0.7);
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
font-size: 16rpx;
|
||||
line-height: 10px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
font-family: "DINCondensed", "PingFang SC", "Helvetica Neue", Arial,
|
||||
sans-serif;
|
||||
transform: translate(-50%, -50%);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.edit-buttons {
|
||||
@@ -311,7 +369,6 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* margin-left: 10px; */
|
||||
}
|
||||
|
||||
.edit-btn-text > text {
|
||||
@@ -349,4 +406,31 @@ onMounted(async () => {
|
||||
right: -12px;
|
||||
bottom: -12px;
|
||||
}
|
||||
.test-view {
|
||||
position: absolute;
|
||||
top: 29px;
|
||||
left: 138px;
|
||||
width: 115px;
|
||||
height: 115px;
|
||||
background-color: #ff000055;
|
||||
}
|
||||
.selected-arrow-point .point {
|
||||
background: linear-gradient(180deg, #ffdaa6 0%, #e9a333 100%) !important;
|
||||
box-shadow: 0rpx 2rpx 4rpx 0rpx rgba(0, 0, 0, 0.18);
|
||||
animation: duang 0.35s ease-out;
|
||||
}
|
||||
@keyframes duang {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.7);
|
||||
}
|
||||
45% {
|
||||
transform: translate(-50%, -50%) scale(1.4);
|
||||
}
|
||||
70% {
|
||||
transform: translate(-50%, -50%) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,14 +26,14 @@ const props = defineProps({
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
font-size: 13px;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.normal {
|
||||
background-image: url("../static/bubble-tip.png");
|
||||
width: 190rpx;
|
||||
width: 157rpx;
|
||||
height: 105rpx;
|
||||
padding-top: 5px;
|
||||
padding-left: 49rpx;
|
||||
padding-top: 10px;
|
||||
padding-left: 30rpx;
|
||||
}
|
||||
.normal2 {
|
||||
background-image: url("../static/bubble-tip4.png");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
import Header from "@/components/Header.vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import BackToGame from "@/components/BackToGame.vue";
|
||||
import { getCurrentGameAPI } from "@/apis";
|
||||
import { debounce } from "@/util";
|
||||
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
||||
import { capsuleHeight, debounce } from "@/util";
|
||||
import AudioManager from "@/audioManager";
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@@ -20,9 +21,9 @@ const props = defineProps({
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
overflow: {
|
||||
type: String,
|
||||
default: "auto",
|
||||
scroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
isHome: {
|
||||
type: Boolean,
|
||||
@@ -40,11 +41,18 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showBottom: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||
const showHint = ref(false);
|
||||
const hintType = ref(0);
|
||||
const capsuleHeight = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const audioInitProgress = ref(1);
|
||||
const audioProgress = ref(0);
|
||||
const audioTimer = ref(null);
|
||||
|
||||
const showGlobalHint = (type) => {
|
||||
hintType.value = type;
|
||||
@@ -55,14 +63,46 @@ const hideGlobalHint = () => {
|
||||
showHint.value = false;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const menuBtnInfo = uni.getMenuButtonBoundingClientRect();
|
||||
capsuleHeight.value = menuBtnInfo.top - 9;
|
||||
const restart = () => {
|
||||
uni.restartMiniProgram({
|
||||
path: "/pages/index",
|
||||
});
|
||||
};
|
||||
|
||||
const checkAudioProgress = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
audioInitProgress.value = AudioManager.getLoadProgress();
|
||||
if (audioInitProgress.value === 1) return resolve();
|
||||
audioTimer.value = setInterval(() => {
|
||||
audioProgress.value = AudioManager.getLoadProgress();
|
||||
if (audioProgress.value === 1) {
|
||||
setTimeout(() => {
|
||||
audioInitProgress.value = 1;
|
||||
}, 200);
|
||||
clearInterval(audioTimer.value);
|
||||
resolve();
|
||||
}
|
||||
}, 200);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const audioFinalProgress = computed(() => {
|
||||
const left = 1 - audioInitProgress.value;
|
||||
return Math.max(0, (audioProgress.value - audioInitProgress.value) / left);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (audioTimer.value) clearInterval(audioTimer.value);
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
uni.$showHint = showGlobalHint;
|
||||
uni.$hideHint = hideGlobalHint;
|
||||
uni.$checkAudio = checkAudioProgress;
|
||||
showHint.value = false;
|
||||
});
|
||||
|
||||
@@ -71,37 +111,21 @@ const backToGame = debounce(async () => {
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
|
||||
// 设置请求超时
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('请求超时')), 10000); // 10秒超时
|
||||
});
|
||||
|
||||
const result = await Promise.race([
|
||||
getCurrentGameAPI(),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
// 处理返回结果
|
||||
if (result && result.gameId) {
|
||||
// 跳转到游戏页面
|
||||
uni.navigateTo({
|
||||
url: `/pages/battle-room?gameId=${result.gameId}`
|
||||
});
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: '没有进行中的对局',
|
||||
icon: 'none'
|
||||
});
|
||||
const result = await getBattleAPI();
|
||||
if (result && result.matchId) {
|
||||
await checkAudioProgress();
|
||||
if (result.mode <= 3) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/team-battle/index?battleId=${result.matchId}`,
|
||||
});
|
||||
} else {
|
||||
uni.navigateTo({
|
||||
url: `/pages/melee-battle?battleId=${result.matchId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
showHint.value = false;
|
||||
} catch (error) {
|
||||
console.error('获取当前游戏失败:', error);
|
||||
uni.showToast({
|
||||
title: error.message || '网络请求失败,请重试',
|
||||
icon: 'none'
|
||||
});
|
||||
console.error("获取当前游戏失败:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
@@ -110,6 +134,17 @@ const backToGame = debounce(async () => {
|
||||
const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
const cancelMatching = async () => {
|
||||
uni.$emit("cancelMatching");
|
||||
}
|
||||
|
||||
const goCalibration = async () => {
|
||||
await laserAimAPI();
|
||||
uni.navigateTo({
|
||||
url: "/pages/calibration",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -122,14 +157,25 @@ const goBack = () => {
|
||||
:whiteBackArrow="whiteBackArrow"
|
||||
/>
|
||||
<BackToGame v-if="showBackToGame" />
|
||||
<view
|
||||
class="content"
|
||||
<scroll-view
|
||||
:scroll-y="scroll"
|
||||
:enhanced="true"
|
||||
:bounces="false"
|
||||
:show-scrollbar="false"
|
||||
:style="{
|
||||
height: `calc(100vh - ${capsuleHeight + (isHome ? 0 : 50)}px)`,
|
||||
overflow,
|
||||
height: `calc(100vh - ${capsuleHeight + (isHome ? 0 : 50)}px - ${
|
||||
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
||||
})`,
|
||||
}"
|
||||
>
|
||||
<slot></slot>
|
||||
</scroll-view>
|
||||
<view
|
||||
class="bottom-part"
|
||||
v-if="$slots.bottom && showBottom"
|
||||
:style="{ height: isIOS ? '65px' : '55px', paddingTop: '10px' }"
|
||||
>
|
||||
<slot name="bottom"></slot>
|
||||
</view>
|
||||
<ScreenHint :show="showHint">
|
||||
<view v-if="hintType === 1" class="tip-content">
|
||||
@@ -139,12 +185,8 @@ const goBack = () => {
|
||||
<button hover-class="none" @click="() => (showHint = false)">
|
||||
不进入
|
||||
</button>
|
||||
<button
|
||||
hover-class="none"
|
||||
@click="backToGame"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
{{ isLoading ? '加载中...' : '进入' }}
|
||||
<button hover-class="none" @click="backToGame" :disabled="isLoading">
|
||||
{{ isLoading ? "加载中..." : "进入" }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -164,24 +206,41 @@ const goBack = () => {
|
||||
<button hover-class="none" @click="() => (showHint = false)">
|
||||
取消
|
||||
</button>
|
||||
<button hover-class="none" @click="goBack">确认</button>
|
||||
<button hover-class="none" @click="$clickSound(cancelMatching)">确认</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hintType === 4" class="tip-content">
|
||||
<text>完成智能弓校准,即可解锁全部功能</text>
|
||||
<view>
|
||||
<button hover-class="none" @click="() => (showHint = false)">
|
||||
取消
|
||||
</button>
|
||||
<button hover-class="none" @click="goCalibration">去校准</button>
|
||||
</view>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
<view v-if="audioInitProgress < 1" class="audio-progress">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-11-26/deihtj15xjwcz3c1tx.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view>
|
||||
<view :style="{ width: `${audioFinalProgress * 100}%` }">
|
||||
<!-- <image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-11-24/degu91a7si77sg9jqv.png"
|
||||
mode="widthFix"
|
||||
/> -->
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<text>若加载时间过长,请</text>
|
||||
<button hover-class="none" @click="restart">点击这里重启</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.tip-content {
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
@@ -219,4 +278,62 @@ const goBack = () => {
|
||||
color: #666;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.audio-progress {
|
||||
z-index: 999;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: rgb(0 0 0 / 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.audio-progress > image:nth-child(1) {
|
||||
width: 140rpx;
|
||||
height: 150rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
.audio-progress > view:nth-child(2) {
|
||||
width: 380rpx;
|
||||
height: 6rpx;
|
||||
background: #595959;
|
||||
border-radius: 4rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.audio-progress > view:nth-child(2) > view {
|
||||
background: #ffe431;
|
||||
min-height: 6rpx;
|
||||
border-radius: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.audio-progress > view:nth-child(2) > view > image {
|
||||
width: 46rpx;
|
||||
height: 26rpx;
|
||||
}
|
||||
.audio-progress > view:nth-child(3) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.audio-progress > view:nth-child(3) > text {
|
||||
font-size: 22rpx;
|
||||
color: #a2a2a2;
|
||||
text-align: center;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
.audio-progress > view:nth-child(3) > button {
|
||||
font-size: 22rpx;
|
||||
color: #ffe431;
|
||||
line-height: 32rpx;
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import { joinRoomAPI, createRoomAPI, isGamingAPI } from "@/apis";
|
||||
|
||||
import { joinRoomAPI, createRoomAPI } from "@/apis";
|
||||
import { debounce } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, game } = storeToRefs(store);
|
||||
|
||||
const props = defineProps({
|
||||
onConfirm: {
|
||||
@@ -10,14 +17,20 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const battleMode = ref(1);
|
||||
const step = ref(1);
|
||||
/** 对战模式:0=未选 1=1v1 2=乱斗 3=2v2 4=3v3 */
|
||||
const battleMode = ref(0);
|
||||
/** 靶纸尺寸:0=未选 1=20cm 2=40cm */
|
||||
const targetMode = ref(0);
|
||||
const loading = ref(false);
|
||||
const roomNumber = ref("");
|
||||
|
||||
const createRoom = async () => {
|
||||
const isGaming = await isGamingAPI();
|
||||
if (isGaming) {
|
||||
const createRoom = debounce(async () => {
|
||||
// 校验必填项:对战模式与靶纸均必须选择
|
||||
if (!battleMode.value || !targetMode.value) {
|
||||
uni.showToast({ title: '请完善创建信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (game.value.inBattle) {
|
||||
uni.$showHint(1);
|
||||
return;
|
||||
}
|
||||
@@ -27,79 +40,79 @@ const createRoom = async () => {
|
||||
if (battleMode.value === 2) size = 10;
|
||||
if (battleMode.value === 3) size = 4;
|
||||
if (battleMode.value === 4) size = 6;
|
||||
const result = await createRoomAPI(
|
||||
battleMode.value === 2 ? 2 : 1,
|
||||
battleMode.value === 2 ? 10 : size
|
||||
);
|
||||
if (result.number) roomNumber.value = result.number;
|
||||
step.value = 2;
|
||||
loading.value = false;
|
||||
};
|
||||
const enterRoom = async () => {
|
||||
step.value = 1;
|
||||
props.onConfirm();
|
||||
await joinRoomAPI(roomNumber.value);
|
||||
uni.navigateTo({
|
||||
url: `/pages/battle-room?roomNumber=${roomNumber.value}`,
|
||||
});
|
||||
};
|
||||
|
||||
const setClipboardData = () => {
|
||||
uni.setClipboardData({
|
||||
data: roomNumber.value,
|
||||
success() {
|
||||
uni.showToast({ title: "复制成功" });
|
||||
},
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await createRoomAPI(
|
||||
battleMode.value === 2 ? 2 : 1,
|
||||
battleMode.value === 2 ? 10 : size,
|
||||
targetMode.value*20,
|
||||
);
|
||||
if (result.number) {
|
||||
props.onConfirm();
|
||||
await joinRoomAPI(result.number);
|
||||
uni.navigateTo({
|
||||
url: "/pages/battle-room?roomNumber=" + result.number + "&target=" + targetMode.value,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<view class="container">
|
||||
<image
|
||||
v-if="step === 1"
|
||||
src="../static/choose-battle-mode.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-if="step === 1" class="create-options">
|
||||
<view class="target-options-header">
|
||||
<view class="target-options-header-line-left"></view>
|
||||
<image class="target-options-header-title-img" src="../static/choose-battle-mode.png" mode="widthFix" />
|
||||
<view class="target-options-header-line-right"></view>
|
||||
</view>
|
||||
<view class="create-options">
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 1 }"
|
||||
@click="() => (battleMode = 1)"
|
||||
>
|
||||
<text>对抗模式(1V1)</text>
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 2 }"
|
||||
@click="() => (battleMode = 2)"
|
||||
>
|
||||
<text>乱斗模式(3-10人)</text>
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 3 }"
|
||||
@click="() => (battleMode = 3)"
|
||||
>
|
||||
<text>对抗模式(2V2)</text>
|
||||
<!-- <text>敬请期待</text> -->
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 4 }"
|
||||
@click="() => (battleMode = 4)"
|
||||
>
|
||||
<text>对抗模式(3V3)</text>
|
||||
<!-- <text>敬请期待</text> -->
|
||||
</view>
|
||||
</view>
|
||||
<SButton v-if="step === 1" :onClick="createRoom">下一步</SButton>
|
||||
<view v-if="step === 2" class="room-info">
|
||||
<view>
|
||||
<text>房间号:</text>
|
||||
<text>{{ roomNumber }}</text>
|
||||
</view>
|
||||
<view class="copy-room-number" @click="setClipboardData"
|
||||
>复制房间信息邀请朋友进入</view
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 2 }"
|
||||
@click="() => (battleMode = 2)"
|
||||
>
|
||||
<SButton width="70vw" :onClick="enterRoom">进入房间</SButton>
|
||||
<text>30分钟无人进入则房间无效</text>
|
||||
<text>乱斗模式(3-10人)</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-options-header">
|
||||
<view class="target-options-header-line-left"></view>
|
||||
<view class="target-options-header-title">选择靶纸</view>
|
||||
<view class="target-options-header-line-right"></view>
|
||||
</view>
|
||||
<view class="target-options">
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': targetMode === 1 }"
|
||||
@click="() => (targetMode = 1)"
|
||||
>
|
||||
<text>20厘米全环靶</text>
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': targetMode === 2 }"
|
||||
@click="() => (targetMode = 2)"
|
||||
>
|
||||
<text>40厘米全环靶</text>
|
||||
</view>
|
||||
</view>
|
||||
<SButton :onClick="() => $clickSound(createRoom)">创建房间</SButton>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -110,6 +123,7 @@ const setClipboardData = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 44rpx;
|
||||
}
|
||||
.container > image:first-child {
|
||||
width: 45%;
|
||||
@@ -124,6 +138,50 @@ const setClipboardData = () => {
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.target-options-header{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.target-options-header-title-img{
|
||||
width: 196rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
.target-options-header-title{
|
||||
width: 112rpx;
|
||||
height: 40rpx;
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 400;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
color: #FFEFBA;
|
||||
margin: 0 18rpx;
|
||||
}
|
||||
.target-options-header-line-left{
|
||||
width: 214rpx;
|
||||
height: 0rpx;
|
||||
border-radius: 0rpx 0rpx 0rpx 0rpx;
|
||||
border: 1rpx solid;
|
||||
border-image: linear-gradient(90deg, rgba(133, 119, 96, 0), rgba(133, 119, 96, 1)) 1 1;
|
||||
}
|
||||
.target-options-header-line-right{
|
||||
width: 214rpx;
|
||||
height: 0rpx;
|
||||
border-radius: 0rpx 0rpx 0rpx 0rpx;
|
||||
border: 1rpx solid;
|
||||
border-image: linear-gradient(90deg, rgba(133, 119, 96, 1), rgba(133, 119, 96, 0)) 1 1;
|
||||
}
|
||||
.target-options {
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.battle-btn {
|
||||
width: 45%;
|
||||
height: 55px;
|
||||
@@ -142,42 +200,4 @@ const setClipboardData = () => {
|
||||
border: 4rpx solid #fff3;
|
||||
border-color: #fed847;
|
||||
}
|
||||
/* .battle-close {
|
||||
background-color: #8889;
|
||||
color: #b3b3b3;
|
||||
}
|
||||
.battle-close > text:last-child {
|
||||
font-size: 12px;
|
||||
} */
|
||||
.room-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-top: 40px;
|
||||
}
|
||||
.room-info > view:first-child {
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.room-info > text {
|
||||
color: #888686;
|
||||
font-size: 14px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.room-info > view:last-child {
|
||||
color: #287fff;
|
||||
margin: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.copy-room-number {
|
||||
width: calc(70vw - 20px);
|
||||
color: #fed847;
|
||||
border: 1px solid #fed847;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { ref, watch, onMounted, computed } from "vue";
|
||||
import { getPointBookConfigAPI } from "@/apis";
|
||||
const props = defineProps({
|
||||
itemIndex: {
|
||||
@@ -34,6 +34,11 @@ const groupArrows = [3, 6, 12, 18];
|
||||
const data = ref([]);
|
||||
const selectedIndex = ref(-1);
|
||||
const secondSelectIndex = ref(-1);
|
||||
|
||||
const meter = ref("");
|
||||
const sets = ref("");
|
||||
const arrowAmount = ref("");
|
||||
|
||||
const onSelectItem = (index) => {
|
||||
selectedIndex.value = index;
|
||||
if (props.itemIndex === 0) {
|
||||
@@ -42,11 +47,13 @@ const onSelectItem = (index) => {
|
||||
props.onSelect(props.itemIndex, distances[index]);
|
||||
} else if (props.itemIndex === 2) {
|
||||
props.onSelect(props.itemIndex, data.value[index]);
|
||||
} else if (props.itemIndex === 3 && secondSelectIndex.value !== -1) {
|
||||
props.onSelect(
|
||||
props.itemIndex,
|
||||
`${selectedIndex.value}/${groupArrows[secondSelectIndex.value]}`
|
||||
);
|
||||
} else if (props.itemIndex === 3) {
|
||||
if (secondSelectIndex.value !== -1) {
|
||||
props.onSelect(
|
||||
props.itemIndex,
|
||||
`${selectedIndex.value}/${groupArrows[secondSelectIndex.value]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const onSelectSecondItem = (index) => {
|
||||
@@ -54,15 +61,44 @@ const onSelectSecondItem = (index) => {
|
||||
if (selectedIndex.value !== -1) {
|
||||
props.onSelect(
|
||||
props.itemIndex,
|
||||
`${selectedIndex.value}/${groupArrows[secondSelectIndex.value]}`
|
||||
`${selectedIndex.value < 5 ? selectedIndex.value : sets.value}/${
|
||||
groupArrows[secondSelectIndex.value]
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
const meter = ref("");
|
||||
const onMeterChange = (e) => {
|
||||
meter.value = e.detail.value;
|
||||
props.onSelect(props.itemIndex, e.detail.value);
|
||||
};
|
||||
const onSetsChange = (e) => {
|
||||
if (!e.detail.value) return;
|
||||
sets.value = Math.min(30, Math.max(1, Number(e.detail.value)));
|
||||
if (!sets.value) return;
|
||||
if (secondSelectIndex.value !== -1) {
|
||||
props.onSelect(
|
||||
props.itemIndex,
|
||||
`${sets.value}/${
|
||||
secondSelectIndex.value === 99
|
||||
? arrowAmount.value
|
||||
: groupArrows[secondSelectIndex.value]
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
const onArrowAmountChange = (e) => {
|
||||
if (!e.detail.value) return;
|
||||
arrowAmount.value = Math.min(60, Math.max(1, Number(e.detail.value)));
|
||||
if (!arrowAmount.value) return;
|
||||
if (selectedIndex.value !== -1) {
|
||||
props.onSelect(
|
||||
props.itemIndex,
|
||||
`${selectedIndex.value === 99 ? sets.value : selectedIndex.value}/${
|
||||
arrowAmount.value
|
||||
}`
|
||||
);
|
||||
}
|
||||
};
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
@@ -114,6 +150,19 @@ const loadConfig = () => {
|
||||
}
|
||||
}
|
||||
};
|
||||
const formatSetAndAmount = computed(() => {
|
||||
if (selectedIndex.value === -1 || secondSelectIndex.value === -1)
|
||||
return itemTexts[props.itemIndex];
|
||||
if (selectedIndex.value === 99 && !sets.value)
|
||||
return itemTexts[props.itemIndex];
|
||||
if (secondSelectIndex.value === 99 && !arrowAmount.value)
|
||||
return itemTexts[props.itemIndex];
|
||||
return `${selectedIndex.value === 99 ? sets.value : selectedIndex.value}组/${
|
||||
secondSelectIndex.value === 99
|
||||
? arrowAmount.value
|
||||
: groupArrows[secondSelectIndex.value]
|
||||
}箭`;
|
||||
});
|
||||
onMounted(async () => {
|
||||
const config = uni.getStorageSync("point-book-config");
|
||||
if (config) {
|
||||
@@ -135,24 +184,21 @@ onMounted(async () => {
|
||||
}"
|
||||
>
|
||||
<view @click="() => onExpand(itemIndex, !expand)">
|
||||
<text :style="{ opacity: expand ? 1 : 0 }">{{
|
||||
itemIndex !== 3 ? itemTexts[itemIndex] : "选择组"
|
||||
}}</text>
|
||||
<view></view>
|
||||
<block>
|
||||
<text :style="{ opacity: expand ? 0 : 1 }" v-if="itemIndex === 0">{{
|
||||
<text v-if="expand" :style="{ color: '#999', fontWeight: 'normal' }">{{
|
||||
itemIndex !== 3 ? itemTexts[itemIndex] : "选择组"
|
||||
}}</text>
|
||||
<text v-if="!expand && itemIndex === 0">{{
|
||||
value || itemTexts[itemIndex]
|
||||
}}</text>
|
||||
<text :style="{ opacity: expand ? 0 : 1 }" v-if="itemIndex === 1">{{
|
||||
<text v-if="!expand && itemIndex === 1">{{
|
||||
value && value > 0 ? value + "米" : itemTexts[itemIndex]
|
||||
}}</text>
|
||||
<text :style="{ opacity: expand ? 0 : 1 }" v-if="itemIndex === 2">{{
|
||||
<text v-if="!expand && itemIndex === 2">{{
|
||||
value || itemTexts[itemIndex]
|
||||
}}</text>
|
||||
<text :style="{ opacity: expand ? 0 : 1 }" v-if="itemIndex === 3">{{
|
||||
selectedIndex !== -1 && secondSelectIndex !== -1
|
||||
? `${selectedIndex}组/${groupArrows[secondSelectIndex]}箭`
|
||||
: itemTexts[itemIndex]
|
||||
}}</text>
|
||||
<text v-if="!expand && itemIndex === 3">{{ formatSetAndAmount }}</text>
|
||||
</block>
|
||||
<button hover-class="none">
|
||||
<image
|
||||
@@ -195,10 +241,11 @@ onMounted(async () => {
|
||||
>
|
||||
<input
|
||||
v-model="meter"
|
||||
type="number"
|
||||
placeholder="自定义"
|
||||
placeholder-style="color: #DDDDDD"
|
||||
@focus="() => (selectedIndex = 9)"
|
||||
@change="onMeterChange"
|
||||
@blur="onMeterChange"
|
||||
/>
|
||||
<text>米</text>
|
||||
</view>
|
||||
@@ -219,7 +266,7 @@ onMounted(async () => {
|
||||
<view v-if="itemIndex === 3">
|
||||
<view class="amount-items">
|
||||
<view
|
||||
v-for="i in 12"
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
:style="{
|
||||
borderColor: selectedIndex === i ? '#fed847' : '#eeeeee',
|
||||
@@ -229,9 +276,29 @@ onMounted(async () => {
|
||||
<text>{{ i }}</text>
|
||||
<text>组</text>
|
||||
</view>
|
||||
<view
|
||||
:style="{
|
||||
borderColor: selectedIndex === 99 ? '#fed847' : '#eeeeee',
|
||||
}"
|
||||
>
|
||||
<input
|
||||
placeholder="1 ~ 30"
|
||||
type="number"
|
||||
placeholder-style="color: #DDDDDD"
|
||||
v-model="sets"
|
||||
@focus="() => (selectedIndex = 99)"
|
||||
@blur="onSetsChange"
|
||||
/>
|
||||
<text>组</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
:style="{ marginTop: '5px', marginBottom: '10px', color: '#999999' }"
|
||||
:style="{
|
||||
marginTop: '5px',
|
||||
marginBottom: '10px',
|
||||
color: '#999999',
|
||||
textAlign: 'center',
|
||||
}"
|
||||
>选择每组的箭数</view
|
||||
>
|
||||
<view class="amount-items">
|
||||
@@ -246,6 +313,22 @@ onMounted(async () => {
|
||||
<text>{{ item }}</text>
|
||||
<text>箭</text>
|
||||
</view>
|
||||
<view
|
||||
:style="{
|
||||
borderColor: secondSelectIndex === 99 ? '#fed847' : '#eeeeee',
|
||||
}"
|
||||
>
|
||||
<input
|
||||
placeholder="1 ~ 60"
|
||||
type="number"
|
||||
placeholder-style="color: #DDDDDD"
|
||||
v-model="arrowAmount"
|
||||
maxlength="99"
|
||||
@focus="() => (secondSelectIndex = 99)"
|
||||
@blur="onArrowAmountChange"
|
||||
/>
|
||||
<text>箭</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -269,9 +352,8 @@ onMounted(async () => {
|
||||
justify-content: space-between;
|
||||
height: 50px;
|
||||
}
|
||||
.container > view:first-child > text:first-child {
|
||||
.container > view:first-child > view:first-child {
|
||||
width: 85px;
|
||||
color: #999999;
|
||||
}
|
||||
.container > view:first-child > text:nth-child(2) {
|
||||
font-weight: 500;
|
||||
@@ -352,4 +434,12 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.amount-items > view:last-child {
|
||||
grid-column: 1 / -1;
|
||||
width: 100%;
|
||||
}
|
||||
.amount-items > view:last-child > input {
|
||||
width: 85%;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,7 @@ const bubbleTypes = [
|
||||
<image
|
||||
v-if="!noBg"
|
||||
:src="bubbleTypes[type]"
|
||||
:style="{ top: type === 2 ? '-6%' : '-12%' }"
|
||||
:style="{ top: type === 2 ? '-6%' : '-13%' }"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<slot />
|
||||
@@ -55,6 +55,6 @@ const bubbleTypes = [
|
||||
}
|
||||
.container > view {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
55
src/components/GuideTwo.vue
Normal file
55
src/components/GuideTwo.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
noBg: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image class="shooter2" src="https://static.shelingxingqiu.com/shootmini/static/shooter2.png" mode="widthFix" />
|
||||
<view class="bg-box">
|
||||
<image
|
||||
class="bg"
|
||||
v-if="!noBg"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/long-bubble-border.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<slot />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 26rpx 0 28rpx;
|
||||
margin-bottom: 14rpx;
|
||||
width: clac(100% - 54rpx);
|
||||
}
|
||||
.container .shooter2 {
|
||||
display: block;
|
||||
width: 133rpx;
|
||||
height: 144rpx;
|
||||
}
|
||||
.container .bg-box {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 128rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.container .bg-box .bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,7 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const { user, game } = storeToRefs(store);
|
||||
|
||||
const currentPage = computed(() => {
|
||||
const pages = getCurrentPages();
|
||||
@@ -55,11 +55,15 @@ const signin = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && user.value.sVip !== true);
|
||||
|
||||
const loading = ref(false);
|
||||
const showLoader = ref(false);
|
||||
const pointBook = ref(null);
|
||||
const showProgress = ref(false);
|
||||
const heat = ref(0);
|
||||
/** 房间号按钮动态定位样式(position: fixed,根据胶囊真实位置计算,脱离 flex 流避免挤压标题) */
|
||||
const battleRoomBtnStyle = ref({});
|
||||
|
||||
const updateLoading = (value) => {
|
||||
loading.value = value;
|
||||
};
|
||||
@@ -71,23 +75,35 @@ const updateHot = (value) => {
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 1];
|
||||
if (currentPage.route === "pages/point-book-edit") {
|
||||
pointBook.value = uni.getStorageSync("point-book");
|
||||
}
|
||||
if (
|
||||
currentPage.route === "pages/team-battle" ||
|
||||
currentPage.route === "pages/melee-match"
|
||||
currentPage.route === "pages/point-book-edit" ||
|
||||
currentPage.route === "pages/point-book-detail"
|
||||
) {
|
||||
showLoader.value = true;
|
||||
pointBook.value = uni.getStorageSync("point-book");
|
||||
if (!pointBook.value) {
|
||||
pointBook.value = uni.getStorageSync("last-point-book");
|
||||
}
|
||||
}
|
||||
if (currentPage.route === "pages/team-battle") {
|
||||
showProgress.value = true;
|
||||
// 仅在对战房间页获取胶囊位置,按钮用 fixed 定位精确贴靠胶囊左侧(脱离 flex 流,不挤压标题)
|
||||
if (currentPage.route === "pages/battle-room") {
|
||||
try {
|
||||
const menuButtonRect = uni.getMenuButtonBoundingClientRect();
|
||||
const { windowWidth } = uni.getSystemInfoSync();
|
||||
battleRoomBtnStyle.value = {
|
||||
// 按钮右边缘距视口右侧 = 屏幕宽 - 胶囊左边缘 + 4px 安全间隙
|
||||
right: (windowWidth - menuButtonRect.left + 4) + "px",
|
||||
// 垂直位置与胶囊顶部对齐
|
||||
top: menuButtonRect.top + "px",
|
||||
// 高度与胶囊一致,视觉融合
|
||||
height: menuButtonRect.height + "px",
|
||||
};
|
||||
} catch (e) {
|
||||
// 获取失败时使用 CSS 兜底定位(28vw + 4px 作为 right,8px 作为 top)
|
||||
}
|
||||
}
|
||||
uni.$on("update-header-loading", updateLoading);
|
||||
uni.$on("update-hot", updateHot);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("update-header-loading", updateLoading);
|
||||
uni.$off("update-hot", updateHot);
|
||||
});
|
||||
</script>
|
||||
@@ -115,7 +131,21 @@ onBeforeUnmount(() => {
|
||||
:size="40"
|
||||
borderColor="#333"
|
||||
/>
|
||||
<text class="truncate">{{ user.nickName }}</text>
|
||||
<view
|
||||
v-if="isVip || isSVip"
|
||||
:class="[
|
||||
'point-book-user-name',
|
||||
'member-nickname',
|
||||
isVip ? 'member-nickname--vip' : '',
|
||||
isSVip ? 'member-nickname--svip' : '',
|
||||
]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||
<text v-if="isSVip" class="member-nickname__shine">
|
||||
{{ user.nickName }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="truncate">{{ user.nickName }}</text>
|
||||
<image
|
||||
v-if="heat"
|
||||
:src="`../static/hot${heat}.png`"
|
||||
@@ -129,8 +159,8 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
<block
|
||||
v-if="
|
||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) === -1 ||
|
||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) === 10
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) === -1 ||
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) === 11
|
||||
"
|
||||
>
|
||||
<text>{{ title }}</text>
|
||||
@@ -138,12 +168,12 @@ onBeforeUnmount(() => {
|
||||
<block
|
||||
v-if="
|
||||
title &&
|
||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== -1 &&
|
||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== 10
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) !== -1 &&
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) !== 11
|
||||
"
|
||||
>
|
||||
<view class="first-try-steps">
|
||||
<text :class="title === '-凹造型' ? 'current-step' : ''">凹造型</text>
|
||||
<text :class="title === '-箭前准备' ? 'current-step' : ''">箭前准备</text>
|
||||
<text>-</text>
|
||||
<text :class="title === '-感知距离' ? 'current-step' : ''"
|
||||
>感知距离</text
|
||||
@@ -155,12 +185,6 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
<image
|
||||
:style="{ opacity: showLoader && loading ? 1 : 0 }"
|
||||
src="../static/btn-loading.png"
|
||||
mode="widthFix"
|
||||
class="loading"
|
||||
/>
|
||||
<view v-if="pointBook" class="point-book-info">
|
||||
<text>{{ pointBook.bowType.name }}</text>
|
||||
<text>{{ pointBook.distance }} 米</text>
|
||||
@@ -178,9 +202,26 @@ onBeforeUnmount(() => {
|
||||
}}</text
|
||||
>
|
||||
</view>
|
||||
<view v-if="showProgress" class="battle-progress">
|
||||
<view
|
||||
v-if="
|
||||
currentPage === 'pages/team-battle' ||
|
||||
currentPage === 'pages/team-battle/index'
|
||||
"
|
||||
class="battle-progress"
|
||||
>
|
||||
<HeaderProgress />
|
||||
</view>
|
||||
<!-- 对战房间:整个胶囊为分享按钮,房号从 Store 读取;fixed 定位紧靠系统胶囊左侧 -->
|
||||
<button
|
||||
v-if="currentPage === 'pages/battle-room' && game.roomNumber"
|
||||
open-type="share"
|
||||
hover-class="none"
|
||||
class="battle-room-number"
|
||||
:style="battleRoomBtnStyle"
|
||||
>
|
||||
<text class="battle-room-number__text">房号: {{ game.roomNumber }}</text>
|
||||
<image src="../static/share2.png" mode="widthFix" class="battle-room-number__icon" />
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -224,14 +265,6 @@ onBeforeUnmount(() => {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.loading {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
transition: all 0.3s ease;
|
||||
background-blend-mode: darken;
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
.point-book-info {
|
||||
color: #333;
|
||||
position: fixed;
|
||||
@@ -267,12 +300,47 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.user-header > image:last-child {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
.user-header > text:nth-child(2) {
|
||||
.user-header > text:nth-child(2),
|
||||
.user-header > .point-book-user-name {
|
||||
font-weight: 500;
|
||||
font-size: 30rpx;
|
||||
color: #333333;
|
||||
margin: 0 20rpx;
|
||||
max-width: 300rpx;
|
||||
}
|
||||
/* 对战房间:整个胶囊作为分享按钮,fixed 定位脱离 flex 流,紧贴系统胶囊左侧 */
|
||||
.battle-room-number {
|
||||
position: fixed;
|
||||
/* 兜底定位(JS 获取胶囊位置失败时生效):约 28vw 对应胶囊区域左边缘 */
|
||||
right: calc(28vw + 4px);
|
||||
top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 240rpx;
|
||||
height: 64rpx;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 96rpx;
|
||||
border: 1rpx solid #5b5758;
|
||||
padding: 0;
|
||||
}
|
||||
/* 重置 button 默认边框 */
|
||||
.battle-room-number::after {
|
||||
border: none;
|
||||
}
|
||||
.battle-room-number__text {
|
||||
width: 156rpx;
|
||||
height: 28rpx;
|
||||
font-weight: 400;
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
line-height: 28rpx;
|
||||
}
|
||||
.battle-room-number__icon {
|
||||
width: 25rpx;
|
||||
height: 26rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
@@ -11,107 +13,89 @@ const tips = ref("");
|
||||
const melee = ref(false);
|
||||
const timer = ref(null);
|
||||
const sound = ref(true);
|
||||
const currentSound = ref("");
|
||||
const currentRound = ref(0);
|
||||
const currentRoundEnded = ref(false);
|
||||
const ended = ref(false);
|
||||
const halfTime = ref(false);
|
||||
const currentShot = ref(0);
|
||||
const totalShot = ref(0);
|
||||
const yourTurn = ref(false);
|
||||
/** 标记组件是否已完成挂载,防止 immediate watcher 在挂载前用旧 store 值触发意外播音 */
|
||||
const isMounted = ref(false);
|
||||
|
||||
watch(
|
||||
() => tips.value,
|
||||
(newVal) => {
|
||||
let key = "";
|
||||
if (newVal.includes("红队")) key = "请红方射箭";
|
||||
if (newVal.includes("蓝队")) key = "请蓝方射箭";
|
||||
if (!sound.value) return;
|
||||
// 挂载完成前不播音(避免 immediate store watcher 用旧值触发多余播报)
|
||||
if (!isMounted.value) return;
|
||||
// 空字符串或含"重回"的 tips 均不播音
|
||||
if (!newVal || newVal.includes("重回")) return;
|
||||
let key = [];
|
||||
if (currentRoundEnded.value) {
|
||||
currentRound.value += 1;
|
||||
// 播放当前轮次语音
|
||||
audioManager.play(
|
||||
`第${["一", "二", "三", "四", "五"][currentRound.value - 1]}轮`
|
||||
);
|
||||
key.push(`第${["一", "二", "三", "四", "五"][currentRound.value]}轮`);
|
||||
}
|
||||
// 延迟播放队伍提示音
|
||||
setTimeout(
|
||||
() => {
|
||||
if (key && !yourTurn.value) audioManager.play(key);
|
||||
currentRoundEnded.value = false;
|
||||
yourTurn.value = false;
|
||||
},
|
||||
currentRoundEnded.value ? 1000 : 0
|
||||
key.push(
|
||||
newVal.includes("你")
|
||||
? "轮到你了"
|
||||
: newVal.includes("红队")
|
||||
? "请红方射箭"
|
||||
: "请蓝方射箭"
|
||||
);
|
||||
audioManager.play(key, false);
|
||||
currentRoundEnded.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const updateSound = () => {
|
||||
sound.value = !sound.value;
|
||||
if (!sound.value) audioManager.stop(currentSound.value);
|
||||
audioManager.setMuted(!sound.value);
|
||||
};
|
||||
|
||||
async function onReceiveMessage(messages = []) {
|
||||
if (!sound.value || ended.value) return;
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.ShootResult) {
|
||||
if (melee.value && msg.userId !== user.value.id) return;
|
||||
if (msg.userId === user.value.id) currentShot.value++;
|
||||
if (!halfTime.value && msg.target) {
|
||||
currentSound.value = msg.target.ring
|
||||
? `${msg.target.ring}环`
|
||||
: "未上靶";
|
||||
audioManager.play(currentSound.value);
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.ToSomeoneShoot) {
|
||||
yourTurn.value = user.value.id === msg.userId;
|
||||
} else if (msg.constructor === MESSAGETYPES.InvalidShot) {
|
||||
if (msg.userId === user.value.id) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play("射击无效");
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.AllReady) {
|
||||
const { config } = msg.groupUserStatus;
|
||||
if (config && config.mode === 1) {
|
||||
totalShot.value = config.teamSize === 2 ? 3 : 2;
|
||||
}
|
||||
currentRoundEnded.value = true;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.constructor === MESSAGETYPES.MeleeAllReady) {
|
||||
melee.value = true;
|
||||
halfTime.value = false;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.constructor === MESSAGETYPES.CurrentRoundEnded) {
|
||||
currentShot.value = 0;
|
||||
if (msg.preRoundResult && msg.preRoundResult.currentRound) {
|
||||
currentRound.value = msg.preRoundResult.currentRound;
|
||||
currentRoundEnded.value = true;
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.HalfTimeOver) {
|
||||
halfTime.value = true;
|
||||
audioManager.play("中场休息");
|
||||
} else if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
audioManager.play("比赛结束");
|
||||
} else if (msg.constructor === MESSAGETYPES.FinalShoot) {
|
||||
totalShot.value = 0;
|
||||
audioManager.play("决金箭轮");
|
||||
tips.value = "即将开始...";
|
||||
currentRoundEnded.value = false;
|
||||
} else if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
ended.value = true;
|
||||
} else if (msg.constructor === MESSAGETYPES.BackToGame) {
|
||||
if (msg.battleInfo) {
|
||||
melee.value = msg.battleInfo.config.mode === 2;
|
||||
}
|
||||
async function onReceiveMessage(message) {
|
||||
if (ended.value) return;
|
||||
if (Array.isArray(message)) return;
|
||||
const { type, mode, current, shootData } = message;
|
||||
if (type === MESSAGETYPESV2.BattleStart) {
|
||||
melee.value = Boolean(mode > 3);
|
||||
// 优先使用后端返回的 shootNumber,降级则根据 mode 推算
|
||||
totalShot.value = message.shootNumber ?? (mode === 1 ? 3 : 2);
|
||||
currentRoundEnded.value = true;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play("比赛结束", false);
|
||||
} else if (type === MESSAGETYPESV2.ShootResult) {
|
||||
if (melee.value && current.playerId !== user.value.id) return;
|
||||
// 从 indexMap 按当前用户 id 取已射箭数,由后端维护准确值,不在前端自增。
|
||||
// 注意:后端在 ShootResult 中会将 playerId 重置为 0(无当前射手),
|
||||
// 因此不能依赖 playerId === user.id 判断,改为直接读取 indexMap[user.id]。
|
||||
// indexMap[user.id] 只在本人射箭后才增加,队友射箭时该值不变,逻辑等价且更准确。
|
||||
const myShot = current.indexMap?.[user.value.id];
|
||||
if (myShot !== undefined) currentShot.value = myShot;
|
||||
if (message.shootData) {
|
||||
let key = [];
|
||||
key.push(
|
||||
shootData.ring
|
||||
? `${shootData.ringX ? "X" : shootData.ring}环`
|
||||
: "未上靶"
|
||||
);
|
||||
if (shootData.angle !== null)
|
||||
key.push(`向${getDirectionText(shootData.angle)}调整`);
|
||||
audioManager.play(key, false);
|
||||
}
|
||||
});
|
||||
} else if (type === MESSAGETYPESV2.NewRound) {
|
||||
currentShot.value = 0;
|
||||
currentRound.value = current.round;
|
||||
currentRoundEnded.value = true;
|
||||
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play("射击无效");
|
||||
}
|
||||
}
|
||||
|
||||
const playSound = (key) => {
|
||||
currentSound.value = key;
|
||||
audioManager.play(key);
|
||||
};
|
||||
|
||||
@@ -119,35 +103,46 @@ const onUpdateTips = (newVal) => {
|
||||
tips.value = newVal;
|
||||
};
|
||||
|
||||
const onUpdateTotalShot = (newVal) => {
|
||||
currentShot.value = newVal.currentShot;
|
||||
totalShot.value = newVal.totalShot;
|
||||
};
|
||||
// 监听 Pinia store 中 totalShot 变化,用于比赛恢复时同步箭数(替代 uni.$emit 避免时序问题)
|
||||
// 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决重入时 totalShot 值不变 watch 不触发的问题)
|
||||
watch(() => store.game.totalShot, (newVal) => {
|
||||
if (newVal > 0) {
|
||||
totalShot.value = newVal;
|
||||
currentShot.value = store.game.currentShot;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 监听 Pinia store 中 tips 变化,用于比赛恢复时同步提示文案(替代 uni.$emit 避免时序问题)
|
||||
// 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决 onShow 早于 onMounted 导致 uni.$emit 事件丢失的问题)
|
||||
// 注意:使用 != null 而非 if(newVal),确保空字符串 "" 也能触发清空(避免重新开赛时旧文案残留)
|
||||
watch(() => store.game.tips, (newVal) => {
|
||||
if (newVal != null) {
|
||||
tips.value = newVal;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("update-shot", onUpdateTotalShot);
|
||||
isMounted.value = true;
|
||||
uni.$on("update-tips", onUpdateTips);
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("play-sound", playSound);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("update-shot", onUpdateTotalShot);
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("play-sound", playSound);
|
||||
// 补充取消 update-tips 监听,防止页面重建时监听器叠加
|
||||
uni.$off("update-tips", onUpdateTips);
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<text>{{ tips }}</text>
|
||||
<text>{{ (tips || "").replace(/你/g, "").replace(/重回/g, "") }}</text>
|
||||
<text v-if="totalShot > 0"> ({{ currentShot }}/{{ totalShot }}) </text>
|
||||
<button v-if="!!tips" hover-class="none" @click="updateSound">
|
||||
<image
|
||||
:src="`../static/sound${sound ? '' : '-off'}-yellow.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image :src="`../static/sound${sound ? '' : '-off'}-yellow.png`" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
@@ -159,12 +154,15 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
.container > button:last-child {
|
||||
|
||||
.container>button:last-child {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.container > button:last-child > image {
|
||||
|
||||
.container>button:last-child>image {
|
||||
width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
@@ -11,91 +11,94 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
const playerNames = [
|
||||
"彭妮·希利",
|
||||
"埃琳娜·奥西波娃",
|
||||
"凯西·考夫霍尔德",
|
||||
"乐正青山",
|
||||
"宇文玉兰",
|
||||
"岑思宇",
|
||||
"邬梓瑜",
|
||||
"范子衿",
|
||||
"旗鼓相当的对手",
|
||||
"马乌罗·内斯波利",
|
||||
"埃琳娜·奥西波娃",
|
||||
"凯西·考夫霍尔德",
|
||||
"乐子睿",
|
||||
"时春晓",
|
||||
"柏孤鸿",
|
||||
"东宫锦瑟",
|
||||
"段干流云",
|
||||
];
|
||||
const textStyles = [
|
||||
{
|
||||
color: "#fff9",
|
||||
fontSize: "18px",
|
||||
},
|
||||
{
|
||||
color: "#fff",
|
||||
fontSize: "22px",
|
||||
},
|
||||
{
|
||||
color: "#fed847",
|
||||
fontSize: "30px",
|
||||
},
|
||||
];
|
||||
|
||||
const totalTop = ref(0);
|
||||
const timer = ref(null);
|
||||
const textStyles = ref([]);
|
||||
const rowHeight = 100 / 7;
|
||||
const totalHeight = (playerNames.length / 7) * 100 + 7;
|
||||
|
||||
const getTextStyle = (top) => {
|
||||
const styles = [
|
||||
{
|
||||
color: "#fff9",
|
||||
fontSize: "20px",
|
||||
},
|
||||
{
|
||||
color: "#fff",
|
||||
fontSize: "24px",
|
||||
},
|
||||
{
|
||||
color: "#fed847",
|
||||
fontSize: "30px",
|
||||
},
|
||||
];
|
||||
const data = new Array(14).fill({
|
||||
const currentTop = ref(-totalHeight + rowHeight * 0);
|
||||
const timer = ref(null);
|
||||
|
||||
const getTextStyle = (top, index) => {
|
||||
const count = Math.floor(
|
||||
((totalHeight + (top + rowHeight / 3)) / rowHeight).toFixed(1)
|
||||
);
|
||||
if (index === 12 - count) return textStyles[0];
|
||||
else if (index === 13 - count) return textStyles[1];
|
||||
else if (index === 14 - count) return textStyles[2];
|
||||
else if (index === 15 - count) return textStyles[1];
|
||||
else if (index === 16 - count) return textStyles[0];
|
||||
return {
|
||||
color: "#fff6",
|
||||
fontSize: "16px",
|
||||
});
|
||||
const unitHeight = 100 / 7;
|
||||
let style = {};
|
||||
if (top >= 100 - unitHeight / 2) {
|
||||
for (let j = 0; j < 5; j++) {
|
||||
data[j + 1] = styles[j > 2 ? 4 - j : j];
|
||||
}
|
||||
} else {
|
||||
new Array(7).fill(1).some((_, i) => {
|
||||
if (
|
||||
top >= unitHeight * i - unitHeight / 2 &&
|
||||
top < unitHeight * (i + 1) - unitHeight / 2
|
||||
) {
|
||||
for (let j = 0; j < 5; j++) {
|
||||
data[7 + j + 1 - i] = styles[j > 2 ? 4 - j : j];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return data;
|
||||
fontSize: "14px",
|
||||
};
|
||||
};
|
||||
watch(
|
||||
() => props.onComplete,
|
||||
(newVal, oldVal) => {
|
||||
if (newVal && !oldVal) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
timer.value = setInterval(() => {
|
||||
if (totalTop.value === 100) {
|
||||
clearInterval(timer.value);
|
||||
setTimeout(() => {
|
||||
newVal();
|
||||
}, 1500);
|
||||
} else {
|
||||
totalTop.value += 0.5;
|
||||
}
|
||||
textStyles.value = getTextStyle(totalTop.value);
|
||||
}, 10);
|
||||
(newVal) => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
timer.value = setInterval(() => {
|
||||
const count = Math.round(
|
||||
(
|
||||
(totalHeight + (currentTop.value + rowHeight / 3)) /
|
||||
rowHeight
|
||||
).toFixed(1)
|
||||
);
|
||||
if (count === 10) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
setTimeout(newVal, 1500);
|
||||
return;
|
||||
}
|
||||
// 这里不重置,如果运行超13秒,就不会循环了
|
||||
if (currentTop.value >= -4) {
|
||||
currentTop.value = -totalHeight;
|
||||
} else {
|
||||
currentTop.value += 2;
|
||||
}
|
||||
}, 40);
|
||||
}
|
||||
);
|
||||
onMounted(() => {
|
||||
timer.value = setInterval(() => {
|
||||
if (totalTop.value === 100) {
|
||||
totalTop.value = 0;
|
||||
if (currentTop.value >= -4) {
|
||||
currentTop.value = -totalHeight;
|
||||
} else {
|
||||
totalTop.value += 2;
|
||||
currentTop.value += 2;
|
||||
}
|
||||
textStyles.value = getTextStyle(totalTop.value);
|
||||
}, 40);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -107,37 +110,20 @@ onBeforeUnmount(() => {
|
||||
class="matching-bg"
|
||||
/>
|
||||
<view>
|
||||
<view
|
||||
class="player-names"
|
||||
:style="{
|
||||
top: `${totalTop - 100}%`,
|
||||
}"
|
||||
>
|
||||
<view class="player-names" :style="{ top: `${currentTop}%` }">
|
||||
<text
|
||||
v-for="(name, index) in playerNames"
|
||||
v-for="(name, index) in [...playerNames, ...playerNames]"
|
||||
:key="index"
|
||||
:style="{
|
||||
lineHeight: `${95 / 7}vw`,
|
||||
...(textStyles[index] || {}),
|
||||
}"
|
||||
>
|
||||
{{ name }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="player-names" :style="{ top: `${totalTop}%` }">
|
||||
<text
|
||||
v-for="(name, index) in playerNames"
|
||||
:key="index"
|
||||
:style="{
|
||||
lineHeight: `${95 / 7}vw`,
|
||||
...(textStyles[index + 7] || {}),
|
||||
lineHeight: `${rowHeight}vw`,
|
||||
...getTextStyle(currentTop, index),
|
||||
}"
|
||||
>
|
||||
{{ name }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<button hover-class="none" @click="stopMatch">取消匹配</button>
|
||||
<button hover-class="none" @click="$clickSound(stopMatch)">取消匹配</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -156,7 +142,7 @@ onBeforeUnmount(() => {
|
||||
height: 95vw;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
top: 30.5vw;
|
||||
top: 30vw;
|
||||
}
|
||||
.matching-bg {
|
||||
position: absolute;
|
||||
@@ -176,7 +162,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.player-names {
|
||||
width: 100%;
|
||||
height: 95vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: absolute;
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const scores = ref([]);
|
||||
const currentUser = ref({});
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
watch(
|
||||
() => props.data,
|
||||
(value) => {
|
||||
const mine = value.players.find((p) => p.playerId === user.value.id);
|
||||
currentUser.value = mine;
|
||||
if (mine && mine.arrowHistory) {
|
||||
scores.value = mine.arrowHistory;
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
const onSelect = (userId) => {
|
||||
const user = props.data.players.find((p) => p.playerId === userId);
|
||||
currentUser.value = user;
|
||||
if (user && user.arrowHistory) {
|
||||
scores.value = user.arrowHistory;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
|
||||
<view>
|
||||
<text>5人大乱斗</text>
|
||||
<view @click="onClose">
|
||||
<image src="../static/close-white.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="rank-rows">
|
||||
<view
|
||||
v-for="(player, index) in data.players"
|
||||
:key="index"
|
||||
@click="() => onSelect(player.playerId)"
|
||||
>
|
||||
<image v-if="index === 0" src="../static/champ1.png" mode="widthFix" />
|
||||
<image v-if="index === 1" src="../static/champ2.png" mode="widthFix" />
|
||||
<image v-if="index === 2" src="../static/champ3.png" mode="widthFix" />
|
||||
<view v-if="index > 2" class="rank-view">{{ index + 1 }}</view>
|
||||
<Avatar :src="player.avatar" :size="24" />
|
||||
<text
|
||||
>积分
|
||||
{{
|
||||
player.totalScore > 0 ? "+" + player.totalScore : player.totalScore
|
||||
}}分</text
|
||||
>
|
||||
<text>{{ player.totalRings }}环</text>
|
||||
<text v-for="(arrow, index2) in player.arrowHistory" :key="index2">
|
||||
{{ arrow.ring }}环
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ width: '95%' }">
|
||||
<BowTarget
|
||||
:scores="scores"
|
||||
:avatar="currentUser ? currentUser.avatar : ''"
|
||||
/>
|
||||
</view>
|
||||
<view class="score-text"
|
||||
><text :style="{ color: '#fed847' }">{{ scores.length }}</text
|
||||
>支箭,共<text :style="{ color: '#fed847' }">{{
|
||||
scores.reduce((last, next) => last + next.ring, 0)
|
||||
}}</text
|
||||
>环</view
|
||||
>
|
||||
<view class="score-row">
|
||||
<view
|
||||
v-for="(score, index) in scores"
|
||||
:key="index"
|
||||
class="score-item"
|
||||
:style="{ width: '13vw', height: '13vw' }"
|
||||
>
|
||||
{{ score.ring }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #232323;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.container > view:first-child {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px 0;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
font-size: 20px;
|
||||
}
|
||||
.container > view:first-child > view:last-child {
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 25px;
|
||||
}
|
||||
.container > view:first-child > view:last-child > image {
|
||||
width: 40px;
|
||||
}
|
||||
.score-text {
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.score-row {
|
||||
margin: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.score-item {
|
||||
background-image: url("../static/score-bg.png");
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
color: #fed847;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
margin: 3px;
|
||||
}
|
||||
.rank-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
border-top: 1px solid #fff3;
|
||||
}
|
||||
.rank-rows > view {
|
||||
width: clac(100% - 20px);
|
||||
color: #fff9;
|
||||
border-bottom: 1px solid #fff3;
|
||||
padding: 7px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.rank-rows > view::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
.rank-rows > view > image:first-child,
|
||||
.rank-rows > view > view:first-child {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
margin-right: 10px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.rank-rows > view > view:first-child {
|
||||
background-color: #6d6d6d;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.rank-rows > view > text {
|
||||
margin-left: 10px;
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
width: 25px;
|
||||
}
|
||||
.rank-rows > view > text:nth-child(3) {
|
||||
width: 80px;
|
||||
}
|
||||
.rank-rows > view > text:nth-child(4) {
|
||||
color: #fed847;
|
||||
padding-right: 10px;
|
||||
border-right: 1px solid #fff3;
|
||||
width: 32px;
|
||||
}
|
||||
</style>
|
||||
234
src/components/ModalDialog.vue
Normal file
234
src/components/ModalDialog.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: "取消",
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: "确定",
|
||||
},
|
||||
showCancel: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showConfirm: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
onCancel: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
onConfirm: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.onCancel?.();
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
props.onConfirm?.();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="modal-mask" :style="{ display: show ? 'flex' : 'none' }">
|
||||
<view class="modal-wrap scale-in">
|
||||
<image
|
||||
class="dialog-light"
|
||||
src="../static/common/dialog-light.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="dialog-icon"
|
||||
src="../static/common/dialog-icon.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
|
||||
<view class="dialog-panel">
|
||||
<image
|
||||
class="dialog-bg"
|
||||
src="../static/common/dialog-bg.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="dialog-content">
|
||||
<slot>
|
||||
<text v-if="title" class="dialog-title">{{ title }}</text>
|
||||
<text v-if="content" class="dialog-text">{{ content }}</text>
|
||||
</slot>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="showCancel || showConfirm"
|
||||
class="dialog-actions"
|
||||
:class="{ single: !(showCancel && showConfirm) }"
|
||||
>
|
||||
<view
|
||||
v-if="showCancel"
|
||||
class="dialog-button cancel"
|
||||
@click="handleCancel"
|
||||
>
|
||||
<text>{{ cancelText }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="showConfirm"
|
||||
class="dialog-button confirm"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
<text>{{ confirmText }}</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.modal-mask {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: rgba(0, 0, 0, 0.62);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.modal-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 549rpx;
|
||||
min-height: 318rpx;;
|
||||
padding-top: 168rpx;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dialog-light {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
width: 520rpx;
|
||||
z-index: 1;
|
||||
transform-origin: center center;
|
||||
animation: rotateLight 8s linear infinite;
|
||||
}
|
||||
|
||||
.dialog-icon {
|
||||
position: absolute;
|
||||
top: 70rpx;
|
||||
left: 50%;
|
||||
width: 250rpx;
|
||||
z-index: 5;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.dialog-panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 318rpx;
|
||||
padding: 98rpx 36rpx 40rpx 36rpx;
|
||||
box-sizing: border-box;
|
||||
z-index: 3;
|
||||
border-radius: 24rpx;
|
||||
border: 2rpx solid rgba(249, 213, 161, 0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 24rpx;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dialog-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.dialog-text {
|
||||
margin-top: 10rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 36rpx;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
margin-top: 50rpx;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.dialog-actions.single {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-button {
|
||||
display: flex;
|
||||
width: 232rpx;
|
||||
height: 70rpx;
|
||||
line-height: 70rpx;
|
||||
border-radius: 44rpx;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dialog-button.cancel {
|
||||
color: #fff;
|
||||
background-color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.dialog-button.confirm {
|
||||
color: #000000;
|
||||
background-color: #ffda3f;
|
||||
}
|
||||
|
||||
@keyframes rotateLight {
|
||||
from {
|
||||
transform: translateX(-50%) rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-50%) rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +1,75 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
avatar: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: "",
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const { user } = storeToRefs(useStore());
|
||||
|
||||
const props = defineProps({
|
||||
player: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
scores: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const rowCount = new Array(6).fill(0);
|
||||
|
||||
const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
|
||||
const getMemberNicknameClass = (player = {}) => [
|
||||
"member-nickname",
|
||||
player.vip === true && player.sVip !== true ? "member-nickname--vip" : "",
|
||||
player.sVip === true ? "member-nickname--svip" : "",
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<view
|
||||
class="container"
|
||||
:style="{ borderColor: player.id === user.id ? '#FED847' : '#fff3' }"
|
||||
>
|
||||
<image
|
||||
:style="{ opacity: scores.length === 12 ? 1 : 0 }"
|
||||
:style="{
|
||||
opacity:
|
||||
(scores[0] || []).length + (scores[1] || []).length === 12 ? 1 : 0,
|
||||
}"
|
||||
src="../static/checked-green.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image :src="avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||
<text>{{ name }}</text>
|
||||
<image :src="player.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||
<view
|
||||
v-if="isMember(player)"
|
||||
:class="['player-score-name', ...getMemberNicknameClass(player)]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ player.name }}</text>
|
||||
<text v-if="player.sVip === true" class="member-nickname__shine">
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else>{{ player.name }}</text>
|
||||
<view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{ scores[index] ? `${scores[index].ring}环` : "-" }}</text>
|
||||
<text>{{
|
||||
scores[0] && scores[0][index] ? `${scores[0][index].ring}环` : "-"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{
|
||||
scores[index + 6] ? `${scores[index + 6].ring}环` : "-"
|
||||
scores[1] && scores[1][index] ? `${scores[1][index].ring}环` : "-"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text
|
||||
>{{
|
||||
scores.map((s) => s.ring).reduce((last, next) => last + next, 0)
|
||||
scores
|
||||
.map((s) => s.reduce((last, next) => last + next.ring, 0))
|
||||
.reduce((last, next) => last + next, 0)
|
||||
}}环</text
|
||||
>
|
||||
</view>
|
||||
@@ -85,6 +113,13 @@ const rowCount = new Array(6).fill(0);
|
||||
text-overflow: ellipsis;
|
||||
width: 20%;
|
||||
}
|
||||
.player-score-name {
|
||||
width: 20%;
|
||||
}
|
||||
.player-score-name .member-nickname__text,
|
||||
.player-score-name .member-nickname__shine {
|
||||
font-size: 14px;
|
||||
}
|
||||
.container > view:nth-child(4) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -104,5 +139,6 @@ const rowCount = new Array(6).fill(0);
|
||||
.container > text:nth-child(5) {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
word-break: keep-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,7 +9,7 @@ defineProps({
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
scores: {
|
||||
arrows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
@@ -21,10 +21,6 @@ defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalRing: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const rowCount = new Array(6).fill(0);
|
||||
</script>
|
||||
@@ -60,19 +56,19 @@ const rowCount = new Array(6).fill(0);
|
||||
<view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{ scores[index] ? `${scores[index].ring}环` : "-" }}</text>
|
||||
<text>{{ arrows[index] ? `${arrows[index].ring}环` : "-" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{
|
||||
scores[index + 6] ? `${scores[index + 6].ring}环` : "-"
|
||||
arrows[index + 6] ? `${arrows[index + 6].ring}环` : "-"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{ totalRing }}环</text>
|
||||
<text>{{ arrows.reduce((last, next) => last + next.ring, 0) }}环</text>
|
||||
<text>积分{{ totalScore }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup>
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
|
||||
const props = defineProps({
|
||||
total: {
|
||||
type: Number,
|
||||
@@ -8,6 +10,15 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
removePlayer: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
/** 当前用户是否为房主;仅房主可见踢人按钮 */
|
||||
isOwner: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const seats = new Array(props.total).fill(1);
|
||||
</script>
|
||||
@@ -16,11 +27,16 @@ const seats = new Array(props.total).fill(1);
|
||||
<view class="players">
|
||||
<view v-for="(_, index) in seats" :key="index">
|
||||
<image src="../static/player-bg.png" mode="widthFix" />
|
||||
<image
|
||||
v-if="players[index] && players[index].name"
|
||||
:src="players[index].avatar || '../static/user-icon.png'"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-if="players[index] && players[index].name" class="avatar">
|
||||
<Avatar
|
||||
:src="players[index].avatar || '../static/user-icon.png'"
|
||||
:size="40"
|
||||
/>
|
||||
<text
|
||||
:style="{ opacity: players[index] && !!players[index].state ? 1 : 0 }"
|
||||
>已准备</text
|
||||
>
|
||||
</view>
|
||||
<view v-else class="player-unknow">
|
||||
<image src="../static/question-mark.png" mode="widthFix" />
|
||||
</view>
|
||||
@@ -28,48 +44,69 @@ const seats = new Array(props.total).fill(1);
|
||||
players[index].name
|
||||
}}</text>
|
||||
<text v-else :style="{ color: '#fff9' }">虚位以待</text>
|
||||
<view v-if="index === 0" class="founder">创建者</view>
|
||||
<image
|
||||
<view v-if="index === 0" class="founder">管理员</view>
|
||||
<!-- <image
|
||||
:src="`../static/player-${index + 1}.png`"
|
||||
mode="widthFix"
|
||||
class="player-bg"
|
||||
/>
|
||||
/> -->
|
||||
<!-- 仅房主(isOwner=true)且非空座位时展示踢人按钮 -->
|
||||
<button
|
||||
v-if="index > 0 && players[index] && isOwner"
|
||||
hover-class="none"
|
||||
class="remove-player"
|
||||
@click="() => removePlayer(players[index])"
|
||||
>
|
||||
<image src="../static/close-white.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.players {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
-moz-column-gap: 20px;
|
||||
column-gap: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
row-gap: 20rpx;
|
||||
column-gap: 25rpx;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.players > view {
|
||||
width: calc(50% - 7px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
color: #fff;
|
||||
height: 100px;
|
||||
height: 176rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
.players > view > image:first-child {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
}
|
||||
.players > view > image:nth-child(2) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
margin: 0 10px;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 50%;
|
||||
.avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 24rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
.avatar > text {
|
||||
background-color: #2c261fb3;
|
||||
border: 1rpx solid #a3793f66;
|
||||
color: #fed847;
|
||||
font-size: 16rpx;
|
||||
border-radius: 20rpx;
|
||||
width: 70rpx;
|
||||
text-align: center;
|
||||
margin-top: -16rpx;
|
||||
position: relative;
|
||||
height: 28rpx;
|
||||
line-height: 28rpx;
|
||||
}
|
||||
.players > view > text:nth-child(3) {
|
||||
width: 20vw;
|
||||
@@ -80,30 +117,48 @@ const seats = new Array(props.total).fill(1);
|
||||
.founder {
|
||||
position: absolute;
|
||||
background-color: #fed847;
|
||||
top: 6px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
color: #000;
|
||||
font-size: 10px;
|
||||
padding: 2px 5px;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
.player-bg {
|
||||
/* .player-bg {
|
||||
position: absolute;
|
||||
width: 52px;
|
||||
right: 0;
|
||||
}
|
||||
} */
|
||||
.player-unknow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 10px;
|
||||
border: 1px solid #fff3;
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
margin: 0 24rpx;
|
||||
border: 1rpx solid #fff3;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #69686866;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.player-unknow > image {
|
||||
width: 40%;
|
||||
}
|
||||
.remove-player {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
right: 0;
|
||||
}
|
||||
.remove-player > image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
avatar: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
blueTeam: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
redTeam: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentShooterId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image v-if="avatar" class="avatar" :src="avatar" mode="widthFix" />
|
||||
<view
|
||||
v-if="blueTeam.length && redTeam.length"
|
||||
:style="{ height: 20 + blueTeam.length * 20 + 'px' }"
|
||||
>
|
||||
<view
|
||||
v-for="(player, index) in blueTeam"
|
||||
:key="index"
|
||||
:style="{
|
||||
top: index * 20 + 'px',
|
||||
zIndex: blueTeam.length - index,
|
||||
left: 0,
|
||||
}"
|
||||
>
|
||||
<image
|
||||
class="avatar"
|
||||
:src="player.avatar || '../static/user-icon.png'"
|
||||
mode="widthFix"
|
||||
:style="{
|
||||
borderColor: currentShooterId === player.id ? '#5fadff' : '#fff',
|
||||
}"
|
||||
/>
|
||||
<text
|
||||
:style="{
|
||||
color: currentShooterId === player.id ? '#5fadff' : '#fff',
|
||||
fontSize: currentShooterId === player.id ? 16 : 12 + 'px',
|
||||
}"
|
||||
>
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-if="!avatar"
|
||||
:style="{
|
||||
height: 20 + redTeam.length * 20 + 'px',
|
||||
}"
|
||||
>
|
||||
<view
|
||||
v-for="(player, index) in redTeam"
|
||||
:key="index"
|
||||
:style="{
|
||||
top: index * 20 + 'px',
|
||||
zIndex: redTeam.length - index,
|
||||
right: 0,
|
||||
}"
|
||||
>
|
||||
<text
|
||||
:style="{
|
||||
color: currentShooterId === player.id ? '#ff6060' : '#fff',
|
||||
fontSize: currentShooterId === player.id ? 16 : 12 + 'px',
|
||||
textAlign: 'right',
|
||||
}"
|
||||
>
|
||||
{{ player.name }}
|
||||
</text>
|
||||
<image
|
||||
class="avatar"
|
||||
:src="player.avatar || '../static/user-icon.png'"
|
||||
mode="widthFix"
|
||||
:style="{
|
||||
borderColor: currentShooterId === player.id ? '#ff6060' : '#fff',
|
||||
}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: calc(100% - 30px);
|
||||
margin: 0 15px;
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.container > view {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
}
|
||||
.container > view > view {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s linear;
|
||||
}
|
||||
.container > view > view > text {
|
||||
margin: 0 10px;
|
||||
overflow: hidden;
|
||||
width: 120px;
|
||||
transition: all 0.3s linear;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.red-avatar {
|
||||
border: 1px solid #ff6060;
|
||||
}
|
||||
.blue-avatar {
|
||||
border: 1px solid #5fadff;
|
||||
}
|
||||
</style>
|
||||
182
src/components/PointRankItem.vue
Normal file
182
src/components/PointRankItem.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const { user, device, online } = storeToRefs(useStore());
|
||||
|
||||
import { clickLikeAPI } from "@/apis";
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
borderWidth: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const like = ref(props.data.ifLike);
|
||||
const likeCount = ref(props.data.likeTotal || 0);
|
||||
|
||||
const isMember = (data = {}) => data.vip === true || data.sVip === true;
|
||||
|
||||
const getMemberNicknameClass = (data = {}) => [
|
||||
"point-rank-name",
|
||||
"member-nickname",
|
||||
data.vip === true && data.sVip !== true ? "member-nickname--vip" : "",
|
||||
data.sVip === true ? "member-nickname--svip" : "",
|
||||
];
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newVal) => {
|
||||
like.value = newVal.ifLike;
|
||||
likeCount.value = newVal.likeTotal || 0;
|
||||
}
|
||||
);
|
||||
|
||||
const onClick = async () => {
|
||||
if (!user.value.id) return;
|
||||
if (user.value.id === props.data.id) {
|
||||
return uni.navigateTo({
|
||||
url: "/pages/my-like-list",
|
||||
});
|
||||
}
|
||||
like.value = !like.value;
|
||||
await clickLikeAPI(props.data.id, like.value);
|
||||
if (like.value) likeCount.value++;
|
||||
else likeCount.value--;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="rank-item" :style="{ borderWidth: borderWidth + 'rpx' }">
|
||||
<image v-if="data.rank === 1" src="../static/point-no1.png" />
|
||||
<image v-else-if="data.rank === 2" src="../static/point-no2.png" />
|
||||
<image v-else-if="data.rank === 3" src="../static/point-no3.png" />
|
||||
<text v-else>{{ data.rank || "" }}</text>
|
||||
<view>
|
||||
<Avatar :src="data.avatar || '../static/user-icon.png'" :size="36" />
|
||||
<view>
|
||||
<view v-if="isMember(data)" :class="getMemberNicknameClass(data)">
|
||||
<text class="member-nickname__text">{{ data.name }}</text>
|
||||
<text v-if="data.sVip === true" class="member-nickname__shine">
|
||||
{{ data.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="truncate">{{ data.name }}</text>
|
||||
<view>
|
||||
<text>{{ data.totalDay }}天</text>
|
||||
<view />
|
||||
<text>平均{{ Number(data.averageRing.toFixed(1)) }}环</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text>{{ data.weekArrow }}</text>
|
||||
<text>箭</text>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<text>{{ Math.round(data.weekArrow * 1.6) }}</text>
|
||||
<text>千卡</text>
|
||||
</view>
|
||||
<button hover-class="none" @click="onClick">
|
||||
<text>{{ likeCount }}</text>
|
||||
<image
|
||||
:src="`../static/like-${like ? 'on' : 'off'}.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rank-item {
|
||||
margin: 0 20rpx;
|
||||
border-bottom: $uni-border;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $uni-white;
|
||||
height: 120rpx;
|
||||
}
|
||||
.rank-item > text:nth-child(1) {
|
||||
width: 52rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
text-align: center;
|
||||
}
|
||||
.rank-item > image:nth-child(1) {
|
||||
width: 52rpx;
|
||||
height: 56rpx;
|
||||
}
|
||||
.rank-item > view:nth-child(2) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
.rank-item > view:nth-child(2) > view:last-child {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 22rpx;
|
||||
color: #aaaaaa;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
.rank-item > view:nth-child(2) > view:last-child > text:first-child {
|
||||
width: 200rpx;
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
.rank-item > view:nth-child(2) > view:last-child > .point-rank-name {
|
||||
width: 200rpx;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
.point-rank-name .member-nickname__text,
|
||||
.point-rank-name .member-nickname__shine {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.rank-item > view:nth-child(2) > view:last-child > view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.rank-item > view:nth-child(2) > view:last-child > view > view {
|
||||
height: 20rpx;
|
||||
width: 1rpx;
|
||||
margin: 0 10rpx;
|
||||
background-color: #b3b3b3;
|
||||
}
|
||||
.item-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
font-size: 20rpx;
|
||||
color: #777777;
|
||||
width: 20%;
|
||||
}
|
||||
.item-info > text:first-child {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
margin-right: 5rpx;
|
||||
}
|
||||
.rank-item > button:nth-child(5) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 22rpx;
|
||||
color: #777777;
|
||||
padding-left: 20rpx;
|
||||
padding-right: 10rpx;
|
||||
}
|
||||
.rank-item > button:nth-child(5) > image {
|
||||
width: 24rpx;
|
||||
height: 22rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@@ -11,6 +11,19 @@ const bowOptions = ref({});
|
||||
const targetOptions = ref({});
|
||||
|
||||
const toDetailPage = () => {
|
||||
const config = uni.getStorageSync("point-book-config");
|
||||
const bowType = config.bowOption.find(
|
||||
(item) => item.id === props.data.bowType
|
||||
);
|
||||
const bowtargetType = config.targetOption.find(
|
||||
(item) => item.id === props.data.targetType
|
||||
);
|
||||
uni.setStorageSync("point-book", {
|
||||
bowType,
|
||||
bowtargetType,
|
||||
distance: props.data.distance,
|
||||
amountGroup: props.data.groups,
|
||||
});
|
||||
uni.navigateTo({
|
||||
url: `/pages/point-book-detail?id=${props.data.id}`,
|
||||
});
|
||||
@@ -29,7 +42,7 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<view class="container" @click="toDetailPage">
|
||||
<view>
|
||||
<view class="left-part">
|
||||
<view class="labels">
|
||||
<view></view>
|
||||
<text>{{
|
||||
@@ -51,12 +64,12 @@ onMounted(() => {
|
||||
<text>平均:{{ data.averageRing }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view class="right-part">
|
||||
<image src="../static/bow-target.png" mode="widthFix" />
|
||||
<view class="arrow-amount">
|
||||
<text>共</text>
|
||||
<text>{{ data.arrows * data.groups }}</text>
|
||||
<text>箭</text>
|
||||
<text>{{ data.actualTotalRing }}</text>
|
||||
<text>/</text>
|
||||
<text>{{ data.totalRing }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -68,15 +81,15 @@ onMounted(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 25rpx;
|
||||
margin-bottom: 25rpx;
|
||||
height: 200rpx;
|
||||
border: 2rpx solid #fed848;
|
||||
padding-left: 30rpx;
|
||||
padding-right: 10rpx;
|
||||
}
|
||||
.container > view {
|
||||
position: relative;
|
||||
margin-left: 15px;
|
||||
}
|
||||
.container > view:first-child {
|
||||
.left-part {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -84,20 +97,24 @@ onMounted(() => {
|
||||
height: calc(100% - 50rpx);
|
||||
color: #333333;
|
||||
}
|
||||
.container > view:first-child > view {
|
||||
.left-part > view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
.container > view:first-child > view:nth-child(3) {
|
||||
.left-part > view:nth-child(3) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 20rpx;
|
||||
color: #666;
|
||||
}
|
||||
.container > view:first-child > view:nth-child(3) > text {
|
||||
.left-part > view:nth-child(3) > text {
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.right-part > image {
|
||||
width: 180rpx;
|
||||
height: 180rpx;
|
||||
}
|
||||
.labels {
|
||||
align-items: flex-end !important;
|
||||
}
|
||||
@@ -115,28 +132,21 @@ onMounted(() => {
|
||||
position: relative;
|
||||
color: #333;
|
||||
}
|
||||
.container > view:last-child {
|
||||
margin-right: 1vw;
|
||||
}
|
||||
.container > view:last-child > image {
|
||||
width: 24vw;
|
||||
}
|
||||
.arrow-amount {
|
||||
position: absolute;
|
||||
background-color: #0009;
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
color: #fffc;
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
width: 60px;
|
||||
font-size: 24rpx;
|
||||
line-height: 26px;
|
||||
width: 64px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
top: calc(50% - 13px);
|
||||
left: calc(50% - 30px);
|
||||
top: calc(50% - 15px);
|
||||
left: calc(50% - 32px);
|
||||
}
|
||||
.arrow-amount > text:nth-child(2) {
|
||||
.arrow-amount > text:nth-child(1) {
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
margin: 0 3px;
|
||||
}
|
||||
</style>
|
||||
|
||||
71
src/components/PointSwitcher.vue
Normal file
71
src/components/PointSwitcher.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
onChange: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const mode = ref(true);
|
||||
|
||||
const onClick = () => {
|
||||
mode.value = !mode.value;
|
||||
props.onChange(mode.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="point-switcher"
|
||||
:style="{ borderColor: mode ? '#D8D8D8' : '#53EF56' }"
|
||||
>
|
||||
<view
|
||||
@click="onClick"
|
||||
:style="{ transform: 'translateX(' + (mode ? '-58' : '4') + 'rpx)' }"
|
||||
>
|
||||
<text>放大</text>
|
||||
<view :style="{ background: mode ? '#D8D8D8' : '#53EF56' }"></view>
|
||||
<text>真实</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.point-switcher {
|
||||
width: 100rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 22rpx;
|
||||
border: 2rpx solid;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
.point-switcher > view {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 40rpx;
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
font-size: 20rpx;
|
||||
word-break: keep-all;
|
||||
padding: 0 12rpx;
|
||||
transition: all 0.3s ease;
|
||||
transform: translateX(-58rpx);
|
||||
}
|
||||
.point-switcher > view > text:first-child {
|
||||
color: #53ef56;
|
||||
}
|
||||
.point-switcher > view > view {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
margin: 0 10rpx;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.point-switcher > view > text:last-child {
|
||||
color: #d8d8d8;
|
||||
}
|
||||
</style>
|
||||
@@ -148,7 +148,7 @@ onMounted(async () => {
|
||||
.container > image:first-child {
|
||||
width: 200rpx;
|
||||
position: absolute;
|
||||
top: -114rpx;
|
||||
top: -112rpx;
|
||||
}
|
||||
.container > text:nth-child(2) {
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const barColor = (rate) => {
|
||||
@@ -44,12 +40,12 @@ const ringText = (ring) => {
|
||||
<view>
|
||||
<view v-for="(b, index) in bars" :key="index">
|
||||
<text v-if="b && b.rate">
|
||||
{{ total === 0 ? `${Number((b.rate * 100).toFixed(1))}%` : b.rate }}
|
||||
{{ `${Number((b.rate * 100).toFixed(1))}%` }}
|
||||
</text>
|
||||
<view
|
||||
:style="{
|
||||
background: barColor(total === 0 ? b.rate : b.rate / total),
|
||||
height: (total === 0 ? b.rate : b.rate / total) * 300 + 'rpx',
|
||||
background: barColor(b.rate),
|
||||
height: (b.rate === 1 ? 150 : b.rate * 240) + 'rpx',
|
||||
}"
|
||||
>
|
||||
</view>
|
||||
@@ -60,18 +56,27 @@ const ringText = (ring) => {
|
||||
{{ b && b.ring !== undefined ? b.ring : "" }}
|
||||
</text>
|
||||
</view>
|
||||
<text>环值</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
min-height: 150rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
.container > text {
|
||||
position: absolute;
|
||||
bottom: 2rpx;
|
||||
left: 0;
|
||||
font-size: 18rpx;
|
||||
color: #999999;
|
||||
}
|
||||
.container > view {
|
||||
padding: 0 10rpx;
|
||||
padding-left: 40rpx;
|
||||
padding-right: 10rpx;
|
||||
}
|
||||
.container > view:first-child {
|
||||
display: flex;
|
||||
@@ -92,14 +97,15 @@ const ringText = (ring) => {
|
||||
transition: all 0.3s ease;
|
||||
height: 0;
|
||||
}
|
||||
.container > view:last-child {
|
||||
.container > view:nth-child(2) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
border-top: 1rpx solid #333;
|
||||
font-size: 22rpx;
|
||||
color: #333333;
|
||||
padding-top: 2rpx;
|
||||
}
|
||||
.container > view:last-child > text {
|
||||
.container > view:nth-child(2) > text {
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -52,14 +52,14 @@ onBeforeUnmount(() => {
|
||||
<view class="point-view1" v-if="bluePoint !== 0 || redPoint !== 0">
|
||||
<text>本轮蓝队</text>
|
||||
<text>{{
|
||||
(roundData.blueArrows || []).reduce(
|
||||
(roundData.shoots[1] || []).reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
)
|
||||
}}</text>
|
||||
<text>环,红队</text>
|
||||
<text>{{
|
||||
(roundData.redArrows || []).reduce(
|
||||
(roundData.shoots[2] || []).reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
)
|
||||
@@ -117,10 +117,10 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-size: 32rpx;
|
||||
}
|
||||
.round-end-tip > text:first-child {
|
||||
font-size: 18px;
|
||||
font-size: 36rpx;
|
||||
color: #fff;
|
||||
}
|
||||
.point-view1 {
|
||||
@@ -137,7 +137,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.point-view2 {
|
||||
margin: 12px 0;
|
||||
font-size: 24px;
|
||||
font-size: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -155,7 +155,8 @@ onBeforeUnmount(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
font-size: 28rpx;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.final-shoot > text:nth-child(1) {
|
||||
width: 20px;
|
||||
@@ -163,7 +164,7 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.final-shoot > text:nth-child(1),
|
||||
.final-shoot > text:nth-child(3) {
|
||||
font-size: 18px;
|
||||
font-size: 32rpx;
|
||||
color: #fed847;
|
||||
margin-left: 10px;
|
||||
margin-right: 5px;
|
||||
|
||||
@@ -7,7 +7,7 @@ const props = defineProps({
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: "260px",
|
||||
default: "650rpx",
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
@@ -56,7 +56,7 @@ watch(
|
||||
>
|
||||
<image
|
||||
v-if="!noBg"
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-08-05/dbuaf19pf7qd8ps0uh.png"
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-12-04/dep11770wzxg6o2alo.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="close-btn" @click="onClose" v-if="!noBg">
|
||||
@@ -81,13 +81,14 @@ watch(
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 99;
|
||||
z-index: 999;
|
||||
}
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
transform: translateY(100%);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
background-color: #372E1D;
|
||||
}
|
||||
.modal-content > image:first-child {
|
||||
width: 100%;
|
||||
|
||||
@@ -9,7 +9,7 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
scores: {
|
||||
arrows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
@@ -51,7 +51,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<view class="container">
|
||||
<image
|
||||
v-if="total > 0 && scores.length === total && completeEffect"
|
||||
v-if="total > 0 && arrows.length === total && completeEffect"
|
||||
:src="bgImages[bgIndex]"
|
||||
class="complete-light"
|
||||
:style="{
|
||||
@@ -79,8 +79,10 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<image src="../static/score-bg.png" mode="widthFix" />
|
||||
<text
|
||||
:style="{ fontWeight: scores[index] !== undefined ? 'bold' : 'normal' }"
|
||||
>{{ scores[index] !== undefined ? scores[index] : "-" }}</text
|
||||
:style="{ fontWeight: arrows[index] !== undefined ? 'bold' : 'normal' }"
|
||||
>{{
|
||||
!arrows[index] ? "-" : arrows[index].ringX ? "X" : arrows[index].ring
|
||||
}}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
scores: {
|
||||
arrows: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
@@ -10,37 +10,34 @@ const getSum = (a, b, c) => {
|
||||
return sum > 0 ? sum + "环" : "-";
|
||||
};
|
||||
const roundsName = ["第一轮", "第二轮", "第三轮", "第四轮"];
|
||||
const getShowText = (arrow = {}) => {
|
||||
return arrow.ring ? (arrow.ringX ? "X" : arrow.ring + "环") : "-";
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<view class="container">
|
||||
<view>
|
||||
<text :style="{ transform: 'translateX(-10%)' }">总成绩</text>
|
||||
<text>{{ scores.reduce((last, next) => last + next, 0) }}环</text>
|
||||
<text>{{ arrows.reduce((last, next) => last + next.ring, 0) }}环</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(_, index) in new Array(
|
||||
Math.min(
|
||||
Math.ceil(scores.length / 3) + (scores.length % 3 === 0 ? 1 : 0),
|
||||
Math.ceil(arrows.length / 3) + (arrows.length % 3 === 0 ? 1 : 0),
|
||||
4
|
||||
)
|
||||
).fill(1)"
|
||||
:key="index"
|
||||
>
|
||||
<text>{{ roundsName[index] }}</text>
|
||||
<text>{{
|
||||
scores[index * 3 + 0] ? scores[index * 3 + 0] + "环" : "-"
|
||||
}}</text>
|
||||
<text>{{
|
||||
scores[index * 3 + 1] ? scores[index * 3 + 1] + "环" : "-"
|
||||
}}</text>
|
||||
<text>{{
|
||||
scores[index * 3 + 2] ? scores[index * 3 + 2] + "环" : "-"
|
||||
}}</text>
|
||||
<text>{{ getShowText(arrows[index * 3 + 0]) }}</text>
|
||||
<text>{{ getShowText(arrows[index * 3 + 1]) }}</text>
|
||||
<text>{{ getShowText(arrows[index * 3 + 2]) }}</text>
|
||||
<text :style="{ width: '40%', transform: 'translateX(20%)' }">{{
|
||||
getSum(
|
||||
scores[index * 3 + 0],
|
||||
scores[index * 3 + 1],
|
||||
scores[index * 3 + 2]
|
||||
arrows[index * 3 + 0],
|
||||
arrows[index * 3 + 1],
|
||||
arrows[index * 3 + 2]
|
||||
)
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import IconButton from "@/components/IconButton.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import BowData from "@/components/BowData.vue";
|
||||
import UserUpgrade from "@/components/UserUpgrade.vue";
|
||||
import { wxShare } from "@/util";
|
||||
import { directionAdjusts } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -37,7 +36,6 @@ const showPanel = ref(true);
|
||||
const showComment = ref(false);
|
||||
const showBowData = ref(false);
|
||||
const showUpgrade = ref(false);
|
||||
const finished = ref(false);
|
||||
const totalRing = ref(0);
|
||||
const closePanel = () => {
|
||||
showPanel.value = false;
|
||||
@@ -45,22 +43,33 @@ const closePanel = () => {
|
||||
props.onClose();
|
||||
}, 300);
|
||||
};
|
||||
function onClickShare() {
|
||||
uni.$emit("share-image");
|
||||
}
|
||||
onMounted(() => {
|
||||
if (props.result.lvl > user.value.lvl) {
|
||||
showUpgrade.value = true;
|
||||
}
|
||||
if (props.result.arrows) {
|
||||
totalRing.value = props.result.arrows.reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
);
|
||||
}
|
||||
finished.value =
|
||||
props.result.arrows && props.result.arrows.length === props.total;
|
||||
totalRing.value = (props.result.details || []).reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
);
|
||||
});
|
||||
// setTimeout(() => {
|
||||
// showPanel.value = true;
|
||||
// }, 300);
|
||||
|
||||
const getRing = (arrow) => {
|
||||
if (arrow.ringX) return "X";
|
||||
return arrow.ring ? arrow.ring : "-";
|
||||
};
|
||||
|
||||
const arrows = computed(() => {
|
||||
const data = new Array(props.total).fill({ ring: 0 });
|
||||
(props.result.details || []).forEach((arrow, index) => {
|
||||
data[index] = arrow;
|
||||
});
|
||||
return data;
|
||||
});
|
||||
|
||||
const validArrows = computed(() => arrows.value.filter((a) => !!a.ring).length);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -69,8 +78,8 @@ onMounted(() => {
|
||||
<image :src="tipSrc" mode="widthFix" />
|
||||
<image src="../static/finish-frame.png" mode="widthFix" />
|
||||
<text
|
||||
>完成<text class="gold-text">{{ result.arrows.length }}</text
|
||||
>箭,获得<text class="gold-text">{{ result.arrows.length }}</text
|
||||
>完成<text class="gold-text">{{ validArrows }}</text
|
||||
>箭,获得<text class="gold-text">{{ validArrows }}</text
|
||||
>点经验</text
|
||||
>
|
||||
</view>
|
||||
@@ -91,16 +100,16 @@ onMounted(() => {
|
||||
</view>
|
||||
<view :style="{ gridTemplateColumns: `repeat(${rowCount}, 1fr)` }">
|
||||
<view v-for="(_, index) in new Array(total).fill(0)" :key="index">
|
||||
{{ result.arrows[index] ? result.arrows[index].ring : 0
|
||||
}}<text>环</text>
|
||||
{{ getRing(arrows[index])
|
||||
}}<text v-if="getRing(arrows[index]) !== '-'">环</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<block v-if="finished">
|
||||
<block v-if="validArrows === total">
|
||||
<IconButton
|
||||
name="分享"
|
||||
src="../static/share.png"
|
||||
:onClick="wxShare"
|
||||
:onClick="onClickShare"
|
||||
/>
|
||||
<IconButton
|
||||
name="教练点评"
|
||||
@@ -109,10 +118,10 @@ onMounted(() => {
|
||||
/>
|
||||
</block>
|
||||
<SButton
|
||||
:width="finished ? '70vw' : 'calc(100vw - 20px)'"
|
||||
:width="validArrows === total ? '70vw' : 'calc(100vw - 20px)'"
|
||||
:rounded="30"
|
||||
:onClick="closePanel"
|
||||
>{{ finished ? "完成" : "重新挑战" }}</SButton
|
||||
>{{ validArrows === total ? "完成" : "返回" }}</SButton
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
@@ -128,28 +137,35 @@ onMounted(() => {
|
||||
}}</text
|
||||
>环的成绩,所有箭支上靶后的平均点间距为<text
|
||||
:style="{ color: '#fed847' }"
|
||||
>{{ Number(result.average_distance.toFixed(2)) }}</text
|
||||
>{{ Number((result.average_distance || 0).toFixed(2)) }}</text
|
||||
>,{{
|
||||
result.spreadEvaluation === "Dispersed"
|
||||
? "还需要持续改进。"
|
||||
? "还需要持续改进哦~"
|
||||
: "成绩优秀。"
|
||||
}}
|
||||
</text>
|
||||
<text :style="{ marginTop: '12px' }"
|
||||
>针对您本次的练习,{{
|
||||
result.spreadEvaluation === "Dispersed"
|
||||
? "我们建议您充分练习推弓、靠位以及撒放动作一致性,以持续提高成绩。"
|
||||
: totalRing >= 100
|
||||
? "我们建议您继续保持即可。"
|
||||
: `我们建议您将设备的瞄准器${
|
||||
directionAdjusts[result.adjustmentHint]
|
||||
}调整。`
|
||||
}}</text
|
||||
>
|
||||
<view>
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-11-26/deihtj15xjwcz3c1tx.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text :style="{ marginTop: '12px' }"
|
||||
>针对您本次的练习,{{
|
||||
result.spreadEvaluation === "Dispersed"
|
||||
? "我们建议您充分练习推弓、靠位以及撒放动作一致性。"
|
||||
: totalRing >= 100
|
||||
? "我们建议您继续保持即可。"
|
||||
: `我们建议您将设备的瞄准器${
|
||||
directionAdjusts[result.adjustmentHint]
|
||||
}调整。`
|
||||
}}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
<BowData
|
||||
:arrows="result.arrows"
|
||||
:total="arrows.length"
|
||||
:arrows="result.details"
|
||||
:show="showBowData"
|
||||
:onClose="() => (showBowData = false)"
|
||||
/>
|
||||
@@ -169,7 +185,7 @@ onMounted(() => {
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
z-index: 5;
|
||||
z-index: 999;
|
||||
}
|
||||
.container-header {
|
||||
margin-top: 20vh;
|
||||
@@ -226,11 +242,12 @@ onMounted(() => {
|
||||
text-align: center;
|
||||
line-height: 27px;
|
||||
color: #333333;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.container-content > view:nth-child(2) > view > text {
|
||||
font-size: 12px;
|
||||
font-size: 20rpx;
|
||||
color: #666666;
|
||||
margin-left: 3px;
|
||||
margin-left: 5rpx;
|
||||
}
|
||||
.container-content > view:nth-child(3) {
|
||||
width: 100%;
|
||||
@@ -246,6 +263,13 @@ onMounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
margin-top: -20px;
|
||||
}
|
||||
.coach-comment > view {
|
||||
display: flex;
|
||||
}
|
||||
.coach-comment > view > image {
|
||||
width: 420rpx;
|
||||
height: 420rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@ onShow(async () => {
|
||||
class="scroll-list"
|
||||
scroll-y
|
||||
:show-scrollbar="false"
|
||||
enhanced="true"
|
||||
:enhanced="true"
|
||||
:bounces="false"
|
||||
refresher-default-style="white"
|
||||
:refresher-enabled="true"
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { getDirectionText } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -35,17 +38,22 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onStop: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const barColor = ref("#fed847");
|
||||
const remain = ref(props.total);
|
||||
const timer = ref(null);
|
||||
const sound = ref(true);
|
||||
const currentSound = ref("");
|
||||
const currentRound = ref(props.currentRound);
|
||||
const currentRoundEnded = ref(false);
|
||||
const ended = ref(false);
|
||||
const halfTime = ref(false);
|
||||
const wait = ref(0);
|
||||
const transitionStyle = ref("all 1s linear");
|
||||
|
||||
watch(
|
||||
() => props.tips,
|
||||
@@ -53,7 +61,7 @@ watch(
|
||||
let key = "";
|
||||
if (newVal.includes("红队")) key = "请红方射箭";
|
||||
if (newVal.includes("蓝队")) key = "请蓝方射箭";
|
||||
if (key && sound.value) {
|
||||
if (key) {
|
||||
if (currentRoundEnded.value) {
|
||||
currentRound.value += 1;
|
||||
currentRoundEnded.value = false;
|
||||
@@ -72,110 +80,103 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.tips,
|
||||
(newVal) => {
|
||||
if (newVal.includes("红队")) barColor.value = "#FF6060";
|
||||
if (newVal.includes("蓝队")) barColor.value = "#5FADFF";
|
||||
if (newVal.includes("红队") || newVal.includes("蓝队")) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
remain.value = props.total;
|
||||
timer.value = setInterval(() => {
|
||||
if (remain.value > 0) remain.value--;
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.start,
|
||||
(newVal) => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
if (newVal) {
|
||||
remain.value = props.total;
|
||||
timer.value = setInterval(() => {
|
||||
if (remain.value > 0) remain.value--;
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
const updateRemain = (value) => {
|
||||
const resetTimer = (count) => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
remain.value = Math.round(value);
|
||||
const newVal = Math.round(count);
|
||||
|
||||
// 如果剩余时间增加(如重置),瞬间变化无动画
|
||||
if (newVal >= remain.value) {
|
||||
transitionStyle.value = "none";
|
||||
remain.value = newVal;
|
||||
setTimeout(() => {
|
||||
transitionStyle.value = "all 1s linear";
|
||||
}, 50);
|
||||
} else {
|
||||
remain.value = newVal;
|
||||
}
|
||||
|
||||
if (remain.value > 0) {
|
||||
timer.value = setInterval(() => {
|
||||
if (remain.value === 0) {
|
||||
clearInterval(timer.value);
|
||||
props.onStop();
|
||||
}
|
||||
if (remain.value > 0) remain.value--;
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.start,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
resetTimer(props.total);
|
||||
} else {
|
||||
remain.value = 0;
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
const tipContent = computed(() => {
|
||||
if (halfTime.value) {
|
||||
return props.battleId ? "中场休息" : `中场休息(${wait.value}秒)`;
|
||||
}
|
||||
return props.start && remain.value === 0 ? "时间到!" : props.tips;
|
||||
});
|
||||
|
||||
const updateSound = () => {
|
||||
sound.value = !sound.value;
|
||||
if (!sound.value) audioManager.stop(currentSound.value);
|
||||
audioManager.setMuted(!sound.value);
|
||||
};
|
||||
|
||||
async function onReceiveMessage(messages = []) {
|
||||
if (!sound.value || ended.value) return;
|
||||
messages.forEach((msg) => {
|
||||
if (
|
||||
(props.battleId && msg.constructor === MESSAGETYPES.ShootResult) ||
|
||||
(!props.battleId && msg.constructor === MESSAGETYPES.ShootSyncMeArrowID)
|
||||
) {
|
||||
if (props.melee && msg.userId !== user.value.id) return;
|
||||
if (!halfTime.value && msg.target) {
|
||||
currentSound.value = msg.target.ring
|
||||
? `${msg.target.ring}环`
|
||||
: "未上靶";
|
||||
audioManager.play(currentSound.value);
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.InvalidShot) {
|
||||
if (msg.userId === user.value.id) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play("射击无效");
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.AllReady) {
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.constructor === MESSAGETYPES.MeleeAllReady) {
|
||||
halfTime.value = false;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.constructor === MESSAGETYPES.CurrentRoundEnded) {
|
||||
currentRoundEnded.value = true;
|
||||
} else if (msg.constructor === MESSAGETYPES.HalfTimeOver) {
|
||||
halfTime.value = true;
|
||||
audioManager.play("中场休息");
|
||||
} else if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
audioManager.play("比赛结束");
|
||||
} else if (msg.constructor === MESSAGETYPES.FinalShoot) {
|
||||
audioManager.play("决金箭轮");
|
||||
} else if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
ended.value = true;
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||
halfTime.value = false;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play("比赛结束", false);
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
let arrow = {};
|
||||
if (msg.details && Array.isArray(msg.details)) {
|
||||
arrow = msg.details[msg.details.length - 1];
|
||||
} else {
|
||||
if (msg.shootData.playerId !== user.value.id) return;
|
||||
if (msg.shootData) arrow = msg.shootData;
|
||||
}
|
||||
});
|
||||
let key = [];
|
||||
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶");
|
||||
if (arrow.angle !== null)
|
||||
key.push(`向${getDirectionText(arrow.angle)}调整`);
|
||||
audioManager.play(key, false);
|
||||
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
|
||||
halfTime.value = true;
|
||||
audioManager.play("中场休息");
|
||||
} else if (msg.type === MESSAGETYPESV2.InvalidShot) {
|
||||
uni.showToast({
|
||||
title: "距离不足,无效",
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play("射击无效");
|
||||
}
|
||||
}
|
||||
|
||||
const playSound = (key) => {
|
||||
currentSound.value = key;
|
||||
audioManager.play(key);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("update-ramain", updateRemain);
|
||||
uni.$on("update-remain", resetTimer);
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("play-sound", playSound);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("update-ramain", updateRemain);
|
||||
uni.$off("update-remain", resetTimer);
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("play-sound", playSound);
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
@@ -186,7 +187,7 @@ onBeforeUnmount(() => {
|
||||
<view class="container" :style="{ display: show ? 'block' : 'none' }">
|
||||
<view>
|
||||
<image src="../static/shooter.png" mode="widthFix" />
|
||||
<text>{{ start && remain === 0 ? "时间到!" : tips }}</text>
|
||||
<text>{{ tipContent }}</text>
|
||||
<button hover-class="none" @click="updateSound">
|
||||
<image
|
||||
:src="`../static/sound${sound ? '' : '-off'}-yellow.png`"
|
||||
@@ -200,6 +201,7 @@ onBeforeUnmount(() => {
|
||||
width: `${(remain / total) * 100}%`,
|
||||
backgroundColor: barColor,
|
||||
right: tips.includes('红队') ? 0 : 'unset',
|
||||
transition: transitionStyle,
|
||||
}"
|
||||
/>
|
||||
<text>剩余{{ remain }}秒</text>
|
||||
@@ -254,7 +256,6 @@ onBeforeUnmount(() => {
|
||||
height: 15px;
|
||||
border-radius: 15px;
|
||||
z-index: -1;
|
||||
transition: all 1s linear;
|
||||
}
|
||||
.container > view:last-child > text {
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { RoundGoldImages } from "@/constants";
|
||||
import {ref, watch, onMounted, onBeforeUnmount} from "vue";
|
||||
import {RoundGoldImages} from "@/constants";
|
||||
|
||||
const props = defineProps({
|
||||
tips: {
|
||||
type: String,
|
||||
@@ -19,57 +20,117 @@ const props = defineProps({
|
||||
const barColor = ref("");
|
||||
const remain = ref(15);
|
||||
const timer = ref(null);
|
||||
|
||||
watch(
|
||||
() => props.tips,
|
||||
(newVal) => {
|
||||
if (newVal.includes("红队"))
|
||||
barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
|
||||
if (newVal.includes("蓝队"))
|
||||
barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
|
||||
if (newVal.includes("红队") || newVal.includes("蓝队")) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
remain.value = props.total;
|
||||
timer.value = setInterval(() => {
|
||||
if (remain.value > 0) remain.value--;
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
const loading = ref(false);
|
||||
const transitionStyle = ref("all 1s linear");
|
||||
const currentTeam = ref(null);
|
||||
|
||||
const updateRemain = (value) => {
|
||||
if (value.stop) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
return
|
||||
}
|
||||
// zeroThenReset:ToSomeoneShoot 到达时,若进度条仍在倒计时则先瞬间清零(约 150ms 停留)再显示下一玩家满值
|
||||
// 若进度条已到 0(loading 状态),直接切换满值
|
||||
if (value.zeroThenReset) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
const wasNonZero = remain.value > 0;
|
||||
// 更新下一玩家颜色和方向(在清零和满值时均生效)
|
||||
currentTeam.value = value.team;
|
||||
if (value.team === 'red') barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
|
||||
if (value.team === 'blue') barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
|
||||
transitionStyle.value = "none";
|
||||
if (wasNonZero) {
|
||||
// 瞬间清零,停留约 150ms 后切换为满值
|
||||
remain.value = 0;
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
remain.value = value.value;
|
||||
loading.value = false;
|
||||
setTimeout(() => { transitionStyle.value = "all 1s linear"; }, 50);
|
||||
}, 150);
|
||||
} else {
|
||||
// 已在底部,直接切换满值
|
||||
remain.value = value.value;
|
||||
loading.value = false;
|
||||
setTimeout(() => { transitionStyle.value = "all 1s linear"; }, 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
loading.value = false;
|
||||
currentTeam.value = value.team
|
||||
if (value.team === 'red')
|
||||
barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
|
||||
if (value.team === 'blue')
|
||||
barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
|
||||
if (value.reset) {
|
||||
// 重置前先清除旧计时器,防止超时未射箭时旧 interval 残留,导致进度条震荡
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
// 重置时瞬间跳满格,禁用 CSS 过渡避免从旧值「涨到满」的动画
|
||||
transitionStyle.value = "none";
|
||||
remain.value = value.value;
|
||||
setTimeout(() => {
|
||||
transitionStyle.value = "all 1s linear";
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
const newVal = Math.round(value.value);
|
||||
// 如果剩余时间增加(如轮次切换重置),瞬间变化无动画;否则保持动画
|
||||
if (newVal >= remain.value) {
|
||||
transitionStyle.value = "none";
|
||||
remain.value = newVal;
|
||||
setTimeout(() => {
|
||||
transitionStyle.value = "all 1s linear";
|
||||
}, 50);
|
||||
} else {
|
||||
remain.value = newVal;
|
||||
}
|
||||
|
||||
// 启动前先清除旧计时器,防止多次 {stop:false} 事件叠加多个 interval
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
remain.value = Math.round(value);
|
||||
timer.value = setInterval(() => {
|
||||
loading.value = remain.value === 0;
|
||||
if (remain.value > 0) remain.value--;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.tips,
|
||||
(newVal) => {
|
||||
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("update-ramain", updateRemain);
|
||||
uni.$on("update-remain", updateRemain);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("update-ramain", updateRemain);
|
||||
uni.$off("update-remain", updateRemain);
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image :src="RoundGoldImages[props.currentRound]" mode="widthFix" />
|
||||
<view>
|
||||
<view
|
||||
<image :src="RoundGoldImages[props.currentRound]" mode="widthFix"/>
|
||||
<view
|
||||
:style="{
|
||||
justifyContent: currentTeam==='red' ? 'flex-end' : 'flex-start',
|
||||
}"
|
||||
>
|
||||
<view
|
||||
:style="{
|
||||
width: `${(remain / total) * 100}%`,
|
||||
background: barColor,
|
||||
right: tips.includes('红队') ? 0 : 'unset',
|
||||
right: currentTeam==='red' ? 0 : 'unset',
|
||||
transition: transitionStyle,
|
||||
}"
|
||||
/>
|
||||
<text>剩余{{ remain }}秒</text>
|
||||
<text v-if="!loading">剩余{{ remain }}秒</text>
|
||||
<text v-else>···</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -80,33 +141,37 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 12vw;
|
||||
}
|
||||
|
||||
.container > image {
|
||||
width: 100%;
|
||||
transform: translateY(7px);
|
||||
width: 380rpx;
|
||||
height: 80rpx;
|
||||
transform: translateY(18rpx);
|
||||
}
|
||||
|
||||
.container > view:last-child {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
background-color: #444444;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
height: 15px;
|
||||
line-height: 15px;
|
||||
height: 24rpx;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container > view:last-child > view {
|
||||
position: absolute;
|
||||
height: 15px;
|
||||
height: 24rpx;
|
||||
border-radius: 15px;
|
||||
transition: all 1s linear;
|
||||
}
|
||||
|
||||
.container > view:last-child > text {
|
||||
font-size: 10px;
|
||||
line-height: 15px;
|
||||
font-size: 18rpx;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import { getMyDevicesAPI, loginAPI, getHomeData } from "@/apis";
|
||||
|
||||
import { wxLogin } from "@/util";
|
||||
import {
|
||||
getMyDevicesAPI,
|
||||
loginAPI,
|
||||
getHomeData,
|
||||
getPhoneNumberAPI,
|
||||
getDeviceBatteryAPI,
|
||||
} from "@/apis";
|
||||
|
||||
import useStore from "@/store";
|
||||
const store = useStore();
|
||||
const { updateUser, updateDevice } = store;
|
||||
const { updateUser, updateDevice, updateOnline } = store;
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noBg: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -18,6 +33,7 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
const agree = ref(false);
|
||||
const phone = ref("");
|
||||
const avatarUrl = ref("");
|
||||
const nickName = ref("");
|
||||
const loading = ref(false);
|
||||
@@ -25,6 +41,17 @@ const handleAgree = () => {
|
||||
agree.value = !agree.value;
|
||||
};
|
||||
|
||||
async function getphonenumber(e) {
|
||||
if (e.detail.code) {
|
||||
const wxResult = await wxLogin();
|
||||
const result = await getPhoneNumberAPI({
|
||||
...e.detail,
|
||||
code: wxResult.code,
|
||||
});
|
||||
if (result.phone) phone.value = result.phone;
|
||||
}
|
||||
}
|
||||
|
||||
function onChooseAvatar(e) {
|
||||
avatarUrl.value = e.detail.avatarUrl;
|
||||
}
|
||||
@@ -33,8 +60,29 @@ function onNicknameChange(e) {
|
||||
nickName.value = e.detail.value;
|
||||
}
|
||||
|
||||
const handleLogin = () => {
|
||||
const resetForm = () => {
|
||||
loading.value = false;
|
||||
agree.value = false;
|
||||
phone.value = "";
|
||||
avatarUrl.value = "";
|
||||
nickName.value = "";
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) resetForm();
|
||||
}
|
||||
);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (loading.value) return;
|
||||
if (!phone.value) {
|
||||
return uni.showToast({
|
||||
title: "请获取手机号",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
if (!avatarUrl.value) {
|
||||
return uni.showToast({
|
||||
title: "请选择头像",
|
||||
@@ -53,35 +101,34 @@ const handleLogin = () => {
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
await doLogin();
|
||||
};
|
||||
|
||||
async function doLogin() {
|
||||
loading.value = true;
|
||||
uni.login({
|
||||
provider: "weixin",
|
||||
success: async (loginRes) => {
|
||||
const { code } = loginRes;
|
||||
const fileManager = uni.getFileSystemManager();
|
||||
const avatarBase64 = fileManager.readFileSync(avatarUrl.value, "base64");
|
||||
const base64Url = `data:image/png;base64,${avatarBase64}`;
|
||||
const result = await loginAPI(nickName.value, base64Url, code);
|
||||
const data = await getHomeData();
|
||||
if (data.user) updateUser(data.user);
|
||||
const devices = await getMyDevicesAPI();
|
||||
if (devices.bindings && devices.bindings.length) {
|
||||
updateDevice(
|
||||
devices.bindings[0].deviceId,
|
||||
devices.bindings[0].deviceName
|
||||
);
|
||||
}
|
||||
props.onClose();
|
||||
},
|
||||
fail: (err) => {
|
||||
loading.value = false;
|
||||
uni.showToast({
|
||||
title: "登录失败",
|
||||
icon: "none",
|
||||
});
|
||||
console.error("登录失败:", err);
|
||||
},
|
||||
});
|
||||
try {
|
||||
const wxResult = await wxLogin();
|
||||
const fileManager = uni.getFileSystemManager();
|
||||
const avatarBase64 = fileManager.readFileSync(avatarUrl.value, "base64");
|
||||
const base64Url = `data:image/png;base64,${avatarBase64}`;
|
||||
await loginAPI(phone.value, nickName.value, base64Url, wxResult.code);
|
||||
const data = await getHomeData();
|
||||
if (data.user) updateUser(data.user);
|
||||
const devices = await getMyDevicesAPI();
|
||||
if (devices.bindings && devices.bindings.length) {
|
||||
updateDevice(
|
||||
devices.bindings[0].deviceId,
|
||||
devices.bindings[0].deviceName
|
||||
);
|
||||
const data = await getDeviceBatteryAPI();
|
||||
updateOnline(data.online);
|
||||
}
|
||||
props.onClose();
|
||||
} catch (error) {
|
||||
console.log("login error", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openServiceLink = () => {
|
||||
@@ -105,73 +152,96 @@ const openPrivacyLink = () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
loading.value = false;
|
||||
resetForm();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container" :style="{ background: noBg ? '#fff' : 'none' }">
|
||||
<view class="avatar" :style="{ borderColor: noBg ? '#E3E3E3' : '#fff' }">
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">头像:</text>
|
||||
<button
|
||||
open-type="chooseAvatar"
|
||||
@chooseavatar="onChooseAvatar"
|
||||
class="login-btn"
|
||||
hover-class="none"
|
||||
<SModal :show="show" :onClose="onClose" :noBg="noBg">
|
||||
<view class="container" :style="{ background: noBg ? '#fff' : 'none' }">
|
||||
<view class="avatar" :style="{ borderColor: noBg ? '#E3E3E3' : '#fff3' }">
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">手机:</text>
|
||||
<button
|
||||
:open-type="!phone ? 'getPhoneNumber' : ''"
|
||||
@getphonenumber="getphonenumber"
|
||||
class="login-btn"
|
||||
hover-class="none"
|
||||
>
|
||||
<text v-if="phone" :style="{ color: noBg ? '#333' : '#fff' }">{{
|
||||
phone
|
||||
}}</text>
|
||||
<text v-else :style="{ color: noBg ? '#666' : '#fff9' }"
|
||||
>点击获取</text
|
||||
>
|
||||
<image src="../static/enter.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
<view class="avatar" :style="{ borderColor: noBg ? '#E3E3E3' : '#fff3' }">
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">头像:</text>
|
||||
<button
|
||||
open-type="chooseAvatar"
|
||||
@chooseavatar="onChooseAvatar"
|
||||
class="login-btn"
|
||||
hover-class="none"
|
||||
>
|
||||
<Avatar v-if="avatarUrl" :src="avatarUrl" :size="30" />
|
||||
<text v-else :style="{ color: noBg ? '#666' : '#fff9' }"
|
||||
>点击获取</text
|
||||
>
|
||||
<image src="../static/enter.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
<view
|
||||
class="nickname"
|
||||
:style="{ borderColor: noBg ? '#E3E3E3' : '#fff3' }"
|
||||
>
|
||||
<Avatar v-if="avatarUrl" :src="avatarUrl" :size="30" />
|
||||
<text v-else :style="{ color: noBg ? '#666' : '#fff9' }">点击获取</text>
|
||||
<image src="../static/enter.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
<view class="nickname" :style="{ borderColor: noBg ? '#E3E3E3' : '#fff' }">
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">昵称:</text>
|
||||
<input
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
:placeholder-style="{ color: noBg ? '#666' : '#fff9' }"
|
||||
@change="onNicknameChange"
|
||||
@blur="onNicknameBlur"
|
||||
:style="{ color: noBg ? '#333' : '#fff' }"
|
||||
/>
|
||||
</view>
|
||||
<SButton :rounded="20" width="80vw" :onClick="handleLogin">
|
||||
<block v-if="!loading">
|
||||
<image
|
||||
src="../static/wechat-icon.png"
|
||||
mode="widthFix"
|
||||
class="wechat-icon"
|
||||
/>
|
||||
<text :style="{ color: '#000' }">登录/注册</text>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image
|
||||
src="../static/btn-loading.png"
|
||||
mode="widthFix"
|
||||
class="loading"
|
||||
/>
|
||||
</block>
|
||||
</SButton>
|
||||
<view class="protocol" @click="handleAgree">
|
||||
<view v-if="!agree" :style="{ borderColor: noBg ? '#E3E3E3' : '#fff' }" />
|
||||
<image v-if="agree" src="../static/checked.png" mode="widthFix" />
|
||||
<view>
|
||||
<text>已同意并阅读</text>
|
||||
<view
|
||||
@click.stop="openServiceLink"
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">昵称:</text>
|
||||
<input
|
||||
type="nickname"
|
||||
:value="nickName"
|
||||
placeholder="请输入昵称"
|
||||
:placeholder-style="`color: ${noBg ? '#666' : '#fff9'} `"
|
||||
@input="onNicknameChange"
|
||||
@change="onNicknameChange"
|
||||
:style="{ color: noBg ? '#333' : '#fff' }"
|
||||
>《用户协议》</view
|
||||
>
|
||||
<text>及</text>
|
||||
/>
|
||||
</view>
|
||||
<SButton :rounded="20" width="80vw" :onClick="handleLogin">
|
||||
<block v-if="!loading">
|
||||
<text :style="{ color: '#000' }">手机号快捷登录</text>
|
||||
</block>
|
||||
<block v-else>
|
||||
<image
|
||||
src="../static/btn-loading.png"
|
||||
mode="widthFix"
|
||||
class="loading"
|
||||
/>
|
||||
</block>
|
||||
</SButton>
|
||||
<view class="protocol" @click="handleAgree">
|
||||
<view
|
||||
@click.stop="openPrivacyLink"
|
||||
:style="{ color: noBg ? '#333' : '#fff' }"
|
||||
>《隐私协议》</view
|
||||
>
|
||||
<text>内容</text>
|
||||
v-if="!agree"
|
||||
:style="{ borderColor: noBg ? '#E3E3E3' : '#fff' }"
|
||||
/>
|
||||
<image v-if="agree" src="../static/checked.png" mode="widthFix" />
|
||||
<view>
|
||||
<text>已同意并阅读</text>
|
||||
<view
|
||||
@click.stop="openServiceLink"
|
||||
:style="{ color: noBg ? '#333' : '#ffffff99' }"
|
||||
>《用户协议》</view
|
||||
>
|
||||
<text>及</text>
|
||||
<view
|
||||
@click.stop="openPrivacyLink"
|
||||
:style="{ color: noBg ? '#333' : '#ffffff99' }"
|
||||
>《隐私协议》</view
|
||||
>
|
||||
<text>内容</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</SModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -191,7 +261,7 @@ onShow(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1rpx solid #fff3;
|
||||
border-bottom: 1rpx solid #ffffff1a;
|
||||
}
|
||||
.avatar {
|
||||
margin: 0;
|
||||
@@ -200,7 +270,7 @@ onShow(() => {
|
||||
.nickname > text {
|
||||
width: 20%;
|
||||
font-size: 14px;
|
||||
line-height: 55px;
|
||||
line-height: 120rpx;
|
||||
}
|
||||
.avatar > button > text {
|
||||
color: #fff9;
|
||||
@@ -209,7 +279,7 @@ onShow(() => {
|
||||
.nickname > input {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
line-height: 55px;
|
||||
line-height: 120rpx;
|
||||
}
|
||||
.wechat-icon {
|
||||
width: 24px;
|
||||
@@ -220,8 +290,8 @@ onShow(() => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
margin-top: 15px;
|
||||
font-size: 22rpx;
|
||||
margin: 30rpx 0;
|
||||
color: #8a8a8a;
|
||||
}
|
||||
.protocol > image {
|
||||
@@ -234,7 +304,7 @@ onShow(() => {
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
margin-right: 10px;
|
||||
border: 1rpx solid #fff;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
.protocol > view:last-child {
|
||||
display: flex;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
const props = defineProps({
|
||||
start: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const count = ref(4);
|
||||
const timer = ref(null);
|
||||
const isIos = ref(true);
|
||||
watch(
|
||||
() => props.start,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
count.value = 4;
|
||||
timer.value = setInterval(() => {
|
||||
if (count.value <= 1) {
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
count.value -= 1;
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
onMounted(() => {
|
||||
const deviceInfo = uni.getDeviceInfo();
|
||||
isIos.value = deviceInfo.osName === "ios";
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container" :style="{ top: `calc(50% - ${isIos ? 56 : 64}px)` }">
|
||||
<view class="number pump-in" v-if="count === 3">3</view>
|
||||
<view class="number pump-in" v-if="count === 2">2</view>
|
||||
<view class="number pump-in" v-if="count === 1">1</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
position: absolute;
|
||||
top: calc(50% - 64px);
|
||||
left: calc(50% - 30px);
|
||||
}
|
||||
.number {
|
||||
color: #fff9;
|
||||
font-size: 88px;
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
interval: {
|
||||
@@ -14,13 +14,24 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
onChange: {
|
||||
type: Function,
|
||||
default: (index) => {},
|
||||
},
|
||||
});
|
||||
|
||||
const currentIndex = ref(0);
|
||||
const currentIndex = ref(props.current);
|
||||
|
||||
watch(
|
||||
() => props.current,
|
||||
(index) => {
|
||||
currentIndex.value = index;
|
||||
}
|
||||
);
|
||||
|
||||
const handleChange = (e) => {
|
||||
currentIndex.value = e.detail.current;
|
||||
@@ -75,7 +86,7 @@ const handleChange = (e) => {
|
||||
|
||||
.dots {
|
||||
position: absolute;
|
||||
bottom: 15%;
|
||||
bottom: 2%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
@@ -90,6 +101,6 @@ const handleChange = (e) => {
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
background-color: #000;
|
||||
background-color: #fed847;
|
||||
}
|
||||
</style>
|
||||
|
||||
197
src/components/TargetPicker.vue
Normal file
197
src/components/TargetPicker.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
onConfirm: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTarget = ref(2);
|
||||
const showContainer = ref(false);
|
||||
const showContent = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
showContainer.value = true;
|
||||
setTimeout(() => {
|
||||
showContent.value = true;
|
||||
}, 100);
|
||||
} else {
|
||||
showContent.value = false;
|
||||
setTimeout(() => {
|
||||
showContainer.value = false;
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const handleConfirm = () => {
|
||||
props.onConfirm(selectedTarget.value);
|
||||
props.onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="container"
|
||||
v-if="showContainer"
|
||||
:class="{ 'container-show': showContent }"
|
||||
@click="onClose"
|
||||
>
|
||||
<view
|
||||
class="modal-content"
|
||||
:class="{ 'modal-show': showContent }"
|
||||
@click.stop=""
|
||||
>
|
||||
<view class="header">
|
||||
<view class="header-title">
|
||||
<view class="header-title-line-left"></view>
|
||||
<text>选择靶型</text>
|
||||
<view class="header-title-line-right"></view>
|
||||
</view>
|
||||
<view class="close-btn" @click="onClose">
|
||||
<image src="../static/close-yellow.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-options">
|
||||
<view
|
||||
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 1 }"
|
||||
@click="() => (selectedTarget = 1)"
|
||||
>
|
||||
<text>20厘米全环靶</text>
|
||||
</view>
|
||||
<view style="width: 30rpx"></view>
|
||||
<view
|
||||
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 2 }"
|
||||
@click="() => (selectedTarget = 2)"
|
||||
>
|
||||
<text>40厘米全环靶</text>
|
||||
</view>
|
||||
</view>
|
||||
<SButton width="694rpx" :onClick="handleConfirm">确定</SButton>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #00000099;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
.container-show {
|
||||
opacity: 1;
|
||||
}
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
transform: translateY(100%);
|
||||
transition: all 0.3s ease;
|
||||
background: url("https://static.shelingxingqiu.com/attachment/2025-12-04/dep11770wzxg6o2alo.png")
|
||||
no-repeat center top;
|
||||
background-size: 100% auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 68rpx;
|
||||
padding-top: 44rpx;
|
||||
}
|
||||
.modal-show {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
.header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin-bottom: 44rpx;
|
||||
}
|
||||
.header-title{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.header-title text{
|
||||
width: 196rpx;
|
||||
height: 40rpx;
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 400;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
color: #FFEFBA;
|
||||
}
|
||||
.header-title-line-left{
|
||||
width: 214rpx;
|
||||
height: 0rpx;
|
||||
border-radius: 0rpx 0rpx 0rpx 0rpx;
|
||||
border: 1rpx solid;
|
||||
border-image: linear-gradient(90deg, rgba(133, 119, 96, 1), rgba(133, 119, 96, 0)) 1 1;
|
||||
}
|
||||
.header-title-line-right{
|
||||
width: 214rpx;
|
||||
height: 0rpx;
|
||||
border-radius: 0rpx 0rpx 0rpx 0rpx;
|
||||
border: 1rpx solid;
|
||||
border-image: linear-gradient(90deg, rgba(133, 119, 96, 1), rgba(133, 119, 96, 0)) 1 1;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -10px;
|
||||
}
|
||||
.close-btn > image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.target-options {
|
||||
width: 750rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: 38rpx;
|
||||
}
|
||||
.target-btn {
|
||||
width: 332rpx;
|
||||
height: 92rpx;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
border: 2rpx solid #fff3;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.target-choosen {
|
||||
color: #fed847;
|
||||
border: 4rpx solid #fed847;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
const props = defineProps({
|
||||
isRed: {
|
||||
type: Boolean,
|
||||
@@ -10,43 +10,55 @@ const props = defineProps({
|
||||
default: () => [],
|
||||
},
|
||||
currentShooterId: {
|
||||
type: Number,
|
||||
type: [Number, String],
|
||||
default: "",
|
||||
},
|
||||
youTurn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const players = ref({});
|
||||
const youTurn = ref(false);
|
||||
const currentTeam = ref(false);
|
||||
const firstName = ref("");
|
||||
|
||||
onMounted(() => {
|
||||
props.team.forEach((p, index) => {
|
||||
players.value[p.id] = { sort: index, ...p };
|
||||
// 抽出判断:当前队伍且该玩家排序为 0(队伍首位)
|
||||
const isFirst = (id) =>
|
||||
currentTeam.value && ((players.value[id] || {}).sort || 0) === 0;
|
||||
|
||||
const getPos = (id) => {
|
||||
const sort = (players.value[id] || {}).sort || 0;
|
||||
if (currentTeam.value) {
|
||||
return 30 * (sort + Math.ceil(sort / 2));
|
||||
}
|
||||
return sort * 40;
|
||||
};
|
||||
|
||||
const syncPlayers = () => {
|
||||
const nextPlayers = {};
|
||||
const shooterId = props.currentShooterId;
|
||||
const shooterIndex = props.team.findIndex(
|
||||
(p) => String(p?.id) === String(shooterId)
|
||||
);
|
||||
const nextTeam = [...props.team];
|
||||
|
||||
currentTeam.value = !!shooterId && shooterIndex >= 0;
|
||||
firstName.value = "";
|
||||
|
||||
if (currentTeam.value) {
|
||||
const target = nextTeam.splice(shooterIndex, 1)[0];
|
||||
if (target) {
|
||||
nextTeam.unshift(target);
|
||||
firstName.value = target.name || "";
|
||||
}
|
||||
}
|
||||
|
||||
nextTeam.forEach((p, index) => {
|
||||
if (p?.id) nextPlayers[p.id] = { sort: index, ...p };
|
||||
});
|
||||
});
|
||||
players.value = nextPlayers;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.currentShooterId,
|
||||
(newVal) => {
|
||||
if (!newVal) return;
|
||||
const index = props.team.findIndex((p) => p.id === newVal);
|
||||
youTurn.value = index >= 0;
|
||||
if (index >= 0) {
|
||||
const newPlayers = [...props.team];
|
||||
const target = newPlayers.splice(index, 1)[0];
|
||||
if (target) {
|
||||
newPlayers.unshift(target);
|
||||
firstName.value = target.name;
|
||||
newPlayers.forEach((p, index) => {
|
||||
players.value[p.id] = { sort: index, ...p };
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
[() => props.team, () => props.currentShooterId],
|
||||
syncPlayers,
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -55,39 +67,38 @@ watch(
|
||||
<image
|
||||
:src="isRed ? '../static/flag-red.png' : '../static/flag-blue.png'"
|
||||
class="flag"
|
||||
:style="{ [isRed ? 'left' : 'right']: '10rpx' }"
|
||||
:style="{
|
||||
[isRed ? 'left' : 'right']: '10rpx',
|
||||
top: currentTeam ? '-36rpx' : '-24rpx',
|
||||
}"
|
||||
/>
|
||||
<view
|
||||
v-for="(item, index) in team"
|
||||
:key="index"
|
||||
:key="item.id || index"
|
||||
class="player"
|
||||
:style="{
|
||||
width:
|
||||
(youTurn ? 40 - ((players[item.id] || {}).sort || 0) * 5 : 35) + 'px',
|
||||
height:
|
||||
(youTurn ? 40 - ((players[item.id] || {}).sort || 0) * 5 : 35) + 'px',
|
||||
borderColor: isRed ? '#ff6060' : '#5fadff',
|
||||
width: (isFirst(item.id) ? 80 : 60) + 'rpx',
|
||||
height: (isFirst(item.id) ? 80 : 60) + 'rpx',
|
||||
zIndex: team.length - ((players[item.id] || {}).sort || 0),
|
||||
top: youTurn ? ((players[item.id] || {}).sort || 0) * 2 + 'px' : '6px',
|
||||
left:
|
||||
(isRed
|
||||
? ((players[item.id] || {}).sort || 0) * 20
|
||||
: 40 - ((players[item.id] || {}).sort || 0) * 20) + 'px',
|
||||
border: isFirst(item.id) ? '3.5rpx solid' : '2rpx solid',
|
||||
borderColor: isRed ? '#ff6060' : '#5fadff',
|
||||
top: isFirst(item.id) ? '0rpx' : '12rpx',
|
||||
[isRed ? 'left' : 'right']: getPos(item.id) + 'rpx',
|
||||
}"
|
||||
>
|
||||
<image :src="item.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||
<text
|
||||
v-if="youTurn && ((players[item.id] || {}).sort || 0) === 0"
|
||||
v-if="isFirst(item.id)"
|
||||
:style="{ backgroundColor: isRed ? '#ff6060' : '#5fadff' }"
|
||||
>{{ isRed ? "红队" : "蓝队" }}</text
|
||||
>
|
||||
</view>
|
||||
<text
|
||||
v-if="youTurn"
|
||||
v-if="currentTeam"
|
||||
class="truncate"
|
||||
:style="{
|
||||
color: isRed ? '#ff6060' : '#5fadff',
|
||||
[isRed ? 'left' : 'right']: 0,
|
||||
[isRed ? 'left' : 'right']: '-4rpx',
|
||||
}"
|
||||
>{{ firstName }}</text
|
||||
>
|
||||
@@ -100,22 +111,22 @@ watch(
|
||||
align-items: center;
|
||||
position: relative;
|
||||
width: 20vw;
|
||||
height: 45px;
|
||||
height: 10rpx;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
.container > text {
|
||||
position: absolute;
|
||||
font-size: 10px;
|
||||
font-size: 20rpx;
|
||||
text-align: center;
|
||||
width: 40px;
|
||||
bottom: -12px;
|
||||
width: 80rpx;
|
||||
bottom: -100rpx;
|
||||
}
|
||||
.player {
|
||||
transition: all 0.3s ease;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 1px solid;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.player > image {
|
||||
width: 100%;
|
||||
@@ -123,17 +134,17 @@ watch(
|
||||
}
|
||||
.player > text {
|
||||
position: absolute;
|
||||
font-size: 8px;
|
||||
font-size: 15rpx;
|
||||
text-align: center;
|
||||
width: 40px;
|
||||
left: 0px;
|
||||
bottom: 0px;
|
||||
width: 76rpx;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.flag {
|
||||
position: absolute;
|
||||
width: 45rpx;
|
||||
height: 45rpx;
|
||||
top: -30rpx;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import { roundsName } from "@/constants";
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
data: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
const selected = ref(0);
|
||||
const redScores = ref([]);
|
||||
const blueScores = ref([]);
|
||||
const tabs = ref(["所有轮次"]);
|
||||
const players = ref([]);
|
||||
const allRoundsScore = ref({});
|
||||
const onClickTab = (index) => {
|
||||
selected.value = index;
|
||||
redScores.value = [];
|
||||
blueScores.value = [];
|
||||
const { bluePlayers, redPlayers, roundsData } = props.data;
|
||||
if (index === 0) {
|
||||
Object.keys(bluePlayers).forEach((p) => {
|
||||
allRoundsScore.value[p] = [];
|
||||
Object.values(roundsData).forEach((round) => {
|
||||
allRoundsScore.value[p].push(
|
||||
round[p].reduce((last, next) => last + next.ring, 0)
|
||||
);
|
||||
round[p].forEach((arrow) => {
|
||||
blueScores.value.push(arrow);
|
||||
});
|
||||
});
|
||||
});
|
||||
Object.keys(redPlayers).forEach((p) => {
|
||||
allRoundsScore.value[p] = [];
|
||||
Object.values(roundsData).forEach((round) => {
|
||||
allRoundsScore.value[p].push(
|
||||
round[p].reduce((last, next) => last + next.ring, 0)
|
||||
);
|
||||
round[p].forEach((arrow) => {
|
||||
redScores.value.push(arrow);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
Object.keys(bluePlayers).forEach((p) => {
|
||||
roundsData[index][p].forEach((arrow) => {
|
||||
blueScores.value.push(arrow);
|
||||
});
|
||||
});
|
||||
Object.keys(redPlayers).forEach((p) => {
|
||||
roundsData[index][p].forEach((arrow) => {
|
||||
redScores.value.push(arrow);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
watch(
|
||||
() => props.data,
|
||||
(value) => {
|
||||
if (value.winner === 0) {
|
||||
players.value = [
|
||||
...Object.values(value.redPlayers),
|
||||
...Object.values(value.bluePlayers),
|
||||
];
|
||||
} else if (value.winner === 1) {
|
||||
players.value = [
|
||||
...Object.values(value.bluePlayers),
|
||||
...Object.values(value.redPlayers),
|
||||
];
|
||||
}
|
||||
Object.keys(value.roundsData).forEach((key) => {
|
||||
tabs.value.push(`第${roundsName[key]}轮`);
|
||||
});
|
||||
onClickTab(0);
|
||||
},
|
||||
{ deep: true, immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
|
||||
<view>
|
||||
<text>1v1排位赛</text>
|
||||
<view @click="onClose">
|
||||
<image src="../static/close-white.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
@click="() => onClickTab(index)"
|
||||
:class="selected === index ? 'selected-tab' : ''"
|
||||
>
|
||||
{{ tab }}
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ width: '95%' }">
|
||||
<BowTarget :scores="redScores" :blueScores="blueScores" />
|
||||
</view>
|
||||
<view class="score-row" v-for="(player, index) in players" :key="index">
|
||||
<Avatar
|
||||
:src="player.avatar"
|
||||
:borderColor="data.bluePlayers[player.playerId] ? 1 : 2"
|
||||
/>
|
||||
<view
|
||||
v-if="selected === 0"
|
||||
v-for="(ring, index) in allRoundsScore[player.playerId]"
|
||||
:key="index"
|
||||
class="score-item"
|
||||
:style="{ width: '13vw', height: '13vw' }"
|
||||
>
|
||||
{{ ring }}
|
||||
</view>
|
||||
<view
|
||||
v-if="selected > 0"
|
||||
v-for="(score, index) in data.roundsData[selected][player.playerId]"
|
||||
:key="index"
|
||||
class="score-item"
|
||||
:style="{ width: '13vw', height: '13vw' }"
|
||||
>
|
||||
{{ score.ring }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #232323;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
.container > view:first-child {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
color: #fff;
|
||||
position: relative;
|
||||
font-size: 20px;
|
||||
}
|
||||
.container > view:first-child > view:last-child {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 32px;
|
||||
}
|
||||
.container > view:first-child > view:last-child > image {
|
||||
width: 40px;
|
||||
}
|
||||
.container > view:nth-child(2) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: calc(100% - 20px);
|
||||
color: #fff9;
|
||||
padding: 0 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.container > view:nth-child(2)::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
.container > view:nth-child(2) > view {
|
||||
border: 1px solid #fff9;
|
||||
border-radius: 20px;
|
||||
padding: 7px 10px;
|
||||
margin: 0 5px;
|
||||
font-size: 14px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.selected-tab {
|
||||
background-color: #fed847;
|
||||
border-color: #fed847 !important;
|
||||
color: #000;
|
||||
}
|
||||
.score-row {
|
||||
margin: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.score-item {
|
||||
background-image: url("../static/score-bg.png");
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
color: #fed847;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,8 +5,7 @@ import BowPower from "@/components/BowPower.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
import { checkConnection } from "@/util";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
@@ -20,13 +19,15 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
});
|
||||
const arrow = ref({});
|
||||
const power = ref(0);
|
||||
const distance = ref(0);
|
||||
const debugInfo = ref("");
|
||||
const showsimul = ref(false);
|
||||
const count = ref(15);
|
||||
const count = ref(props.count);
|
||||
const timer = ref(null);
|
||||
|
||||
const updateTimer = (value) => {
|
||||
@@ -34,10 +35,12 @@ const updateTimer = (value) => {
|
||||
};
|
||||
onMounted(() => {
|
||||
audioManager.play("请射箭测试距离");
|
||||
timer.value = setInterval(() => {
|
||||
if (count.value > 0) count.value -= 1;
|
||||
else clearInterval(timer.value);
|
||||
}, 1000);
|
||||
if (props.isBattle) {
|
||||
timer.value = setInterval(() => {
|
||||
count.value -= 1;
|
||||
if (count.value < 0) clearInterval(timer.value);
|
||||
}, 1000);
|
||||
}
|
||||
uni.$on("update-timer", updateTimer);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
@@ -45,19 +48,13 @@ onBeforeUnmount(() => {
|
||||
uni.$off("update-timer", updateTimer);
|
||||
});
|
||||
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
arrow.value = msg.target;
|
||||
power.value = msg.target.battery;
|
||||
distance.value = Number((msg.target.dst / 100).toFixed(2));
|
||||
debugInfo.value = msg.target;
|
||||
audioManager.play("距离合格");
|
||||
} else if (msg.constructor === MESSAGETYPES.InvalidShot) {
|
||||
distance.value = Number((msg.target.dst / 100).toFixed(2));
|
||||
audioManager.play("距离不足");
|
||||
}
|
||||
});
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
||||
distance.value = Number((msg.shootData.distance / 100).toFixed(2));
|
||||
if (distance.value >= 5) audioManager.play("距离合格");
|
||||
else audioManager.play("距离不足");
|
||||
}
|
||||
}
|
||||
|
||||
const simulShoot = async () => {
|
||||
@@ -65,7 +62,6 @@ const simulShoot = async () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkConnection();
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
const accountInfo = uni.getAccountInfoSync();
|
||||
const envVersion = accountInfo.miniProgram.envVersion;
|
||||
@@ -80,18 +76,9 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<view class="container">
|
||||
<Guide v-show="guide">
|
||||
<view
|
||||
:style="{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingRight: '10px',
|
||||
}"
|
||||
>
|
||||
<view :style="{ display: 'flex', flexDirection: 'column' }">
|
||||
<text :style="{ color: '#fed847' }">请确保站距达到5米</text>
|
||||
<text>低于5米的射箭无效</text>
|
||||
</view>
|
||||
<view class="guide-tips">
|
||||
<text>请确保站距达到5米</text>
|
||||
<text>低于5米的射箭无效</text>
|
||||
</view>
|
||||
</Guide>
|
||||
<view class="test-area">
|
||||
@@ -120,16 +107,17 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
<view class="user-row">
|
||||
<Avatar :src="user.avatar" :size="35" />
|
||||
<BowPower :power="power" />
|
||||
<BowPower />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="isBattle" class="ready-timer">
|
||||
<image src="../static/test-tip.png" mode="widthFix" />
|
||||
<view>
|
||||
<view v-if="count >= 0">
|
||||
<text>具体正式比赛还有</text>
|
||||
<text>{{ count }}</text>
|
||||
<text>秒</text>
|
||||
</view>
|
||||
<view v-else> 进入中... </view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +19,8 @@ const nextLvlPoints = ref(0);
|
||||
const containerWidth = computed(() =>
|
||||
props.showRank ? "72%" : "calc(100% - 15px)"
|
||||
);
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||
const toUserPage = () => {
|
||||
// 获取当前页面路径
|
||||
const pages = getCurrentPages();
|
||||
@@ -36,6 +38,7 @@ const toRankListPage = () => {
|
||||
url: "/pages/rank-list",
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [config.value, user.value],
|
||||
([n_config, n_user]) => {
|
||||
@@ -66,9 +69,20 @@ watch(
|
||||
:onClick="toUserPage"
|
||||
:size="42"
|
||||
/>
|
||||
<view class="user-details" :onClick="toUserPage">
|
||||
<view class="user-details" @click="toUserPage">
|
||||
<view class="user-name">
|
||||
<text>{{ user.nickName }}</text>
|
||||
<view
|
||||
:class="[
|
||||
'member-nickname',
|
||||
isVip ? 'member-nickname--vip' : '',
|
||||
isSVip ? 'member-nickname--svip' : '',
|
||||
]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||
<text v-if="isSVip" class="member-nickname__shine">{{
|
||||
user.nickName
|
||||
}}</text>
|
||||
</view>
|
||||
<image
|
||||
class="user-name-image"
|
||||
src="../static/vip1.png"
|
||||
@@ -77,7 +91,6 @@ watch(
|
||||
</view>
|
||||
<view class="user-stats">
|
||||
<text class="level-tag level-tag-first">段位积分</text>
|
||||
<!-- <text class="level-tag level-tag-second">LV{{ user.lvl }}</text> -->
|
||||
<view class="rank-tag">
|
||||
<view
|
||||
class="rank-tag-progress"
|
||||
@@ -112,12 +125,12 @@ watch(
|
||||
</view>
|
||||
</block>
|
||||
<block v-else>
|
||||
<view class="signin">
|
||||
<view class="signin" @click="onSignin">
|
||||
<image src="../static/user-icon.png" mode="widthFix" />
|
||||
<view @click="() => (showModal = true)">
|
||||
<view>
|
||||
<text>新来的弓箭手你好呀~</text>
|
||||
<view @click="onSignin">
|
||||
<text>微信登录</text>
|
||||
<view>
|
||||
<text>登录</text>
|
||||
<image src="../static/enter-arrow-blue.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
@@ -148,17 +161,19 @@ watch(
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.user-name > text:first-child {
|
||||
font-size: 13px;
|
||||
.user-name .member-nickname {
|
||||
max-width: 180rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-name .member-nickname__text,
|
||||
.user-name .member-nickname__shine {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-name-image {
|
||||
margin-left: 5px;
|
||||
width: 20px;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
@@ -172,13 +187,9 @@ watch(
|
||||
}
|
||||
|
||||
.level-tag-first {
|
||||
width: 50px;
|
||||
padding: 0 10rpx;
|
||||
background: #5f51ff;
|
||||
}
|
||||
|
||||
.level-tag-second {
|
||||
width: 60rpx;
|
||||
background: #09c504;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.level-tag,
|
||||
@@ -190,14 +201,17 @@ watch(
|
||||
.rank-tag {
|
||||
position: relative;
|
||||
background-color: #00000038;
|
||||
width: 150rpx;
|
||||
width: 140rpx;
|
||||
overflow: hidden;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.rank-tag-progress {
|
||||
background: #ffa711;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
width: 0;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.rank-tag-text {
|
||||
@@ -210,24 +224,26 @@ watch(
|
||||
}
|
||||
|
||||
.rank-info {
|
||||
width: 70px;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
width: 95px;
|
||||
height: 50px;
|
||||
font-size: 24rpx;
|
||||
position: relative;
|
||||
color: #b3b3b3;
|
||||
padding-left: 8px;
|
||||
margin-left: 15rpx;
|
||||
padding-left: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.rank-info-image {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -9px;
|
||||
width: 90px;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 95px;
|
||||
}
|
||||
.rank-info > text {
|
||||
text-align: center;
|
||||
word-break: keep-all;
|
||||
width: 83px;
|
||||
}
|
||||
.rank-number {
|
||||
display: block;
|
||||
|
||||
@@ -24,17 +24,35 @@ export const MESSAGETYPES = {
|
||||
LvlUpdate: 3958625354,
|
||||
TeamUpdate: 4168086616,
|
||||
InvalidShot: 4168086617,
|
||||
Calibration: 4168086625,
|
||||
DeviceOnline: 4168086626,
|
||||
DeviceOffline: 4168086627,
|
||||
SomeoneIsReady: 4168086628,
|
||||
};
|
||||
|
||||
export const MESSAGETYPESV2 = {
|
||||
AboutToStart: 1,
|
||||
BattleStart: 2,
|
||||
ToSomeoneShoot: 3,
|
||||
ShootResult: 4,
|
||||
NewRound: 5,
|
||||
BattleEnd: 6,
|
||||
HalfRest: 7,
|
||||
TestDistance: 8,
|
||||
MatchSuccess: 9,
|
||||
InvalidShot: 10,
|
||||
};
|
||||
|
||||
export const topThreeColors = ["#FFD947", "#D2D2D2", "#FFA515"];
|
||||
|
||||
export const getMessageTypeName = (id) => {
|
||||
for (let key in MESSAGETYPES) {
|
||||
if (MESSAGETYPES[key] === id) {
|
||||
return key;
|
||||
}
|
||||
if (MESSAGETYPES[key] === id) return key;
|
||||
}
|
||||
return null;
|
||||
for (let key in MESSAGETYPESV2) {
|
||||
if (MESSAGETYPESV2[key] === id) return key;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
export const roundsName = {
|
||||
@@ -100,7 +118,7 @@ export const getBattleResultTips = (
|
||||
) => {
|
||||
const getRandomIndex = (len) => Math.floor(Math.random() * len);
|
||||
if (gameMode === 1) {
|
||||
if (mode === 1) {
|
||||
if (mode <= 3) {
|
||||
if (win) {
|
||||
const tests = [
|
||||
"https://static.shelingxingqiu.com/attachment/2025-08-01/dbqq1fglywucyoh9zn.png",
|
||||
@@ -122,7 +140,7 @@ export const getBattleResultTips = (
|
||||
];
|
||||
return tests[getRandomIndex(3)];
|
||||
}
|
||||
} else if (mode === 2) {
|
||||
} else {
|
||||
if (rank <= 3) {
|
||||
const tests = [
|
||||
"好成绩!全国排位赛等着你!",
|
||||
@@ -134,7 +152,7 @@ export const getBattleResultTips = (
|
||||
}
|
||||
}
|
||||
} else if (gameMode === 2) {
|
||||
if (mode === 1) {
|
||||
if (mode <= 3) {
|
||||
if (win) {
|
||||
const tests = [
|
||||
"https://static.shelingxingqiu.com/attachment/2025-08-01/dbqq1fgtb29jbdus4g.png",
|
||||
@@ -156,7 +174,7 @@ export const getBattleResultTips = (
|
||||
];
|
||||
return tests[getRandomIndex(3)];
|
||||
}
|
||||
} else if (mode === 2) {
|
||||
} else {
|
||||
if (score > 0) {
|
||||
const tests = [
|
||||
"王者一定属于你!",
|
||||
|
||||
15
src/main.js
15
src/main.js
@@ -1,11 +1,26 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import App from './App.vue'
|
||||
import audioManager from './audioManager'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
app.use(pinia)
|
||||
|
||||
/**
|
||||
* 全局点击音效工具函数,用于在任意按钮/元素点击时自动播放音效。
|
||||
* 用法:@click="$clickSound(handler)" 或 @click="$clickSound(() => doSomething())"
|
||||
* @param {Function} handler - 原始点击回调函数(可选,点击时直接调用)
|
||||
* @param {string} [soundKey='点击按钮'] - audioManager 中的音效 key
|
||||
*/
|
||||
app.config.globalProperties.$clickSound = (handler, soundKey = '点击按钮') => {
|
||||
audioManager.play(soundKey);
|
||||
if (typeof handler === 'function') handler();
|
||||
};
|
||||
|
||||
return {
|
||||
app
|
||||
}
|
||||
|
||||
@@ -1,68 +1,70 @@
|
||||
{
|
||||
"name": "shoot-miniprograms",
|
||||
"appid": "",
|
||||
"description": "",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
"transformPx": false,
|
||||
"uniStatistics": {
|
||||
"enable": false
|
||||
},
|
||||
"app-plus": {
|
||||
"bounce": "none",
|
||||
"usingComponents": true,
|
||||
"nvueStyleCompiler": "uni-app",
|
||||
"compilerVersion": 3,
|
||||
"splashscreen": {
|
||||
"alwaysShowBeforeRender": true,
|
||||
"waiting": true,
|
||||
"autoclose": true,
|
||||
"delay": 0
|
||||
"name" : "shoot-miniprograms",
|
||||
"appid" : "__UNI__B03E251",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
"transformPx" : false,
|
||||
"uniStatistics" : {
|
||||
"enable" : false
|
||||
},
|
||||
"modules": {},
|
||||
"distribute": {
|
||||
"android": {
|
||||
"permissions": [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
"app-plus" : {
|
||||
"bounce" : "none",
|
||||
"usingComponents" : true,
|
||||
"nvueStyleCompiler" : "uni-app",
|
||||
"compilerVersion" : 3,
|
||||
"splashscreen" : {
|
||||
"alwaysShowBeforeRender" : true,
|
||||
"waiting" : true,
|
||||
"autoclose" : true,
|
||||
"delay" : 0
|
||||
},
|
||||
"modules" : {},
|
||||
"distribute" : {
|
||||
"android" : {
|
||||
"permissions" : [
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
},
|
||||
"sdkConfigs" : {}
|
||||
}
|
||||
},
|
||||
"h5" : {
|
||||
"darkmode" : true,
|
||||
"themeLocation" : "theme.json"
|
||||
},
|
||||
"quickapp" : {},
|
||||
"mp-weixin" : {
|
||||
"appid" : "wxa8f5989dcd45cc23",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"minified" : true,
|
||||
"uglifyFileName" : true,
|
||||
"useCompilerModule" : true,
|
||||
"useIsolateContext" : true
|
||||
},
|
||||
"lazyCodeLoading" : "requiredComponents",
|
||||
"usingComponents" : true,
|
||||
"darkmode" : true,
|
||||
"themeLocation" : "theme.json",
|
||||
"permission" : {},
|
||||
"requiredPrivateInfos" : [ "getLocation", "chooseLocation" ]
|
||||
}
|
||||
},
|
||||
"h5": {
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json"
|
||||
},
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"appid": "wxa8f5989dcd45cc23",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"minified": true,
|
||||
"uglifyFileName": true,
|
||||
"useCompilerModule": true,
|
||||
"useIsolateContext": true
|
||||
},
|
||||
"lazyCodeLoading": "requiredComponents",
|
||||
"usingComponents": true,
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json",
|
||||
"permission": {},
|
||||
"requiredPrivateInfos": ["getLocation", "chooseLocation"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,24 @@
|
||||
{
|
||||
"path": "pages/index"
|
||||
},
|
||||
{
|
||||
"path": "pages/friend-battle"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-rank"
|
||||
},
|
||||
{
|
||||
"path": "pages/my-like-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/audio-test"
|
||||
},
|
||||
{
|
||||
"path": "pages/calibration"
|
||||
},
|
||||
{
|
||||
"path": "pages/about-us"
|
||||
},
|
||||
@@ -15,11 +30,17 @@
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/team-battle/index"
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-battle"
|
||||
},
|
||||
{
|
||||
"path": "pages/battle-result"
|
||||
},
|
||||
{
|
||||
"path": "pages/team-battle"
|
||||
"path": "pages/friend-battle-result"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-edit"
|
||||
@@ -34,10 +55,10 @@
|
||||
"path": "pages/point-book-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/match-page"
|
||||
"path": "pages/point-book-detail-share"
|
||||
},
|
||||
{
|
||||
"path": "pages/image-share"
|
||||
"path": "pages/match-page"
|
||||
},
|
||||
{
|
||||
"path": "pages/my-device"
|
||||
@@ -49,13 +70,16 @@
|
||||
"path": "pages/user"
|
||||
},
|
||||
{
|
||||
"path": "pages/orders"
|
||||
"path": "pages/member/orders"
|
||||
},
|
||||
{
|
||||
"path": "pages/order-detail"
|
||||
"path": "pages/member/order-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/be-vip"
|
||||
"path": "pages/member/be-vip"
|
||||
},
|
||||
{
|
||||
"path": "pages/member/vip-intro"
|
||||
},
|
||||
{
|
||||
"path": "pages/grade-intro"
|
||||
@@ -79,13 +103,7 @@
|
||||
"path": "pages/practise-two"
|
||||
},
|
||||
{
|
||||
"path": "pages/friend-battle"
|
||||
},
|
||||
{
|
||||
"path": "pages/battle-room",
|
||||
"style": {
|
||||
"disableSwipeBack": true
|
||||
}
|
||||
"path": "pages/battle-room"
|
||||
},
|
||||
{
|
||||
"path": "pages/ranking"
|
||||
@@ -93,17 +111,11 @@
|
||||
{
|
||||
"path": "pages/rank-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/team-match"
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-match"
|
||||
},
|
||||
{
|
||||
"path": "pages/match-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/team-bow-data"
|
||||
"path": "pages/team-battle/team-bow-data"
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-bow-data"
|
||||
@@ -123,5 +135,11 @@
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": false
|
||||
},
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||||
}
|
||||
},
|
||||
"subPackages": []
|
||||
}
|
||||
|
||||
@@ -1,24 +1,7 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import Container from "@/components/Container.vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const isIos = ref(false);
|
||||
|
||||
const openLink = () => {
|
||||
// uni.navigateTo({
|
||||
// url:
|
||||
// "/pages/webview?url=" +
|
||||
// encodeURIComponent("https://beian.miit.gov.cn/"),
|
||||
// });
|
||||
};
|
||||
onMounted(() => {
|
||||
const deviceInfo = uni.getDeviceInfo();
|
||||
isIos.value = deviceInfo.osName === "ios";
|
||||
});
|
||||
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -34,8 +17,7 @@ onMounted(() => {
|
||||
|
||||
<view
|
||||
class="copyright"
|
||||
:style="{ paddingBottom: isIos ? '30rpx' : '20rpx' }"
|
||||
@click="openLink"
|
||||
:style="{ paddingBottom: isIOS ? '40rpx' : '20rpx' }"
|
||||
>
|
||||
<text>粤ICP备2025421150号-2X</text>
|
||||
</view>
|
||||
@@ -46,7 +28,7 @@ onMounted(() => {
|
||||
<style scoped>
|
||||
.container {
|
||||
width: calc(100% - 50rpx);
|
||||
height: 100%;
|
||||
height: calc(100% - 50rpx);
|
||||
padding: 25rpx;
|
||||
background-color: #ffffff;
|
||||
position: relative;
|
||||
|
||||
68
src/pages/audio-test.vue
Normal file
68
src/pages/audio-test.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import Container from "@/components/Container.vue";
|
||||
import audioManager, { audioFils } from "@/audioManager";
|
||||
|
||||
const loaded = ref({});
|
||||
|
||||
const playAudio = (key) => {
|
||||
audioManager.play(key);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const loadedAudioKeys = uni.getStorageSync("loadedAudioKeys") || {};
|
||||
loaded.value = loadedAudioKeys;
|
||||
|
||||
uni.$on("audioLoaded", (key) => {
|
||||
loaded.value[key] = true;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("audioLoaded");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="音频测试">
|
||||
<view class="container">
|
||||
<view>
|
||||
<text>连续播放1</text>
|
||||
<button hover-class="none" @click="playAudio(['第一轮', '请蓝方射箭'])">
|
||||
播放
|
||||
</button>
|
||||
</view>
|
||||
<view>
|
||||
<text>连续播放2</text>
|
||||
<button hover-class="none" @click="playAudio(['第二轮', '请红方射箭'])">
|
||||
播放
|
||||
</button>
|
||||
</view>
|
||||
<view v-for="key in Object.keys(audioFils)" :key="key">
|
||||
<text>{{ key }}</text>
|
||||
<text v-if="!loaded[key]">未加载</text>
|
||||
<button v-else hover-class="none" @click="playAudio(key)">播放</button>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
.container > view {
|
||||
width: calc(100% - 50rpx);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 25rpx;
|
||||
color: #fff;
|
||||
border-bottom: 1rpx solid #fff9;
|
||||
}
|
||||
.container > view > button {
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import UserUpgrade from "@/components/UserUpgrade.vue";
|
||||
import { getGameAPI } from "@/apis";
|
||||
import { getBattleAPI } from "@/apis";
|
||||
import { topThreeColors, getBattleResultTips } from "@/constants";
|
||||
import audioManager from "@/audioManager";
|
||||
import useStore from "@/store";
|
||||
@@ -16,134 +16,108 @@ const ifWin = ref(false);
|
||||
const data = ref({});
|
||||
const totalPoints = ref(0);
|
||||
const rank = ref(0);
|
||||
const players = ref([]);
|
||||
|
||||
function exit() {
|
||||
uni.navigateBack();
|
||||
if (data.value.roomId) {
|
||||
uni.redirectTo({
|
||||
url: `/pages/battle-room?roomNumber=${data.value.roomId}`,
|
||||
});
|
||||
} else {
|
||||
uni.navigateBack();
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (!options.battleId) return;
|
||||
const myId = user.value.id;
|
||||
if (options.battleId) {
|
||||
const result = await getGameAPI(
|
||||
options.battleId || "BATTLE-1758270367040321900-868"
|
||||
const result = await getBattleAPI(options.battleId || "60049406950510592");
|
||||
data.value = result;
|
||||
if (result.winTeam) {
|
||||
ifWin.value = result.teams[result.winTeam].players.some(
|
||||
(p) => p.id === myId
|
||||
);
|
||||
data.value = {
|
||||
...result,
|
||||
battleMode: result.gameMode,
|
||||
};
|
||||
if (result.mode === 1) {
|
||||
data.value.redPlayers = Object.values(result.redPlayers);
|
||||
data.value.bluePlayers = Object.values(result.bluePlayers);
|
||||
if (result.redPlayers[myId]) {
|
||||
totalPoints.value = result.redPlayers[myId].totalScore;
|
||||
data.value.myTeam = result.redPlayers[myId].team;
|
||||
ifWin.value = result.winner === 0;
|
||||
}
|
||||
if (result.bluePlayers[myId]) {
|
||||
totalPoints.value = result.bluePlayers[myId].totalScore;
|
||||
data.value.myTeam = result.bluePlayers[myId].team;
|
||||
ifWin.value = result.winner === 1;
|
||||
}
|
||||
}
|
||||
if (result.mode === 2) {
|
||||
data.value.playerStats = result.players.map((p) => ({
|
||||
...p,
|
||||
id: p.playerId,
|
||||
}));
|
||||
const mine = result.players.find((p) => p.playerId === myId);
|
||||
if (mine) totalPoints.value = mine.totalScore;
|
||||
rank.value = result.players.findIndex((p) => p.playerId === myId) + 1;
|
||||
}
|
||||
} else {
|
||||
const battleInfo = uni.getStorageSync("last-battle");
|
||||
if (!battleInfo) return;
|
||||
data.value = {
|
||||
mvps: [],
|
||||
...battleInfo,
|
||||
};
|
||||
if (battleInfo.mode === 1) {
|
||||
battleInfo.playerStats.forEach((p) => {
|
||||
if (p.team === 1) data.value.bluePlayers = [p];
|
||||
if (p.team === 0) data.value.redPlayers = [p];
|
||||
if (p.mvp) data.value.mvps.push(p);
|
||||
});
|
||||
data.value.mvps.sort((a, b) => b.totalRings - a.totalRings);
|
||||
}
|
||||
rank.value = 0;
|
||||
const mine = battleInfo.playerStats.find((p, index) => {
|
||||
rank.value = index + 1;
|
||||
return p.id === myId;
|
||||
});
|
||||
if (mine) {
|
||||
data.value.myTeam = mine.team;
|
||||
totalPoints.value = mine.totalScore;
|
||||
if (battleInfo.mode === 1) {
|
||||
ifWin.value = mine.team === battleInfo.winner;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.value.mode === 1) {
|
||||
if (result.mode <= 3) {
|
||||
audioManager.play(ifWin.value ? "胜利" : "失败");
|
||||
} else if (data.value.mode === 2) {
|
||||
if (data.value.battleMode === 1) {
|
||||
if (rank.value <= data.value.playerStats.length * 0.3) {
|
||||
audioManager.play("胜利");
|
||||
}
|
||||
} else if (data.value.battleMode === 2) {
|
||||
if (totalPoints.value > 0) {
|
||||
audioManager.play("胜利");
|
||||
} else if (totalPoints.value < 0) {
|
||||
audioManager.play("失败");
|
||||
} else {
|
||||
players.value = result.resultList.map((item, index) => {
|
||||
const plist = result.teams[0] ? result.teams[0].players : [];
|
||||
const p = plist.find((p) => p.id === item.userId);
|
||||
if (p.id === user.value.id) {
|
||||
totalPoints.value = p.score;
|
||||
rank.value = index + 1;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
rank: index + 1,
|
||||
name: p.name,
|
||||
avatar: p.avatar || "",
|
||||
};
|
||||
});
|
||||
if (rank.value <= players.value * 0.3) {
|
||||
audioManager.play("胜利");
|
||||
} else {
|
||||
audioManager.play("胜利");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const myTeam = computed(() => {
|
||||
const teams = data.value.teams;
|
||||
if (teams && teams.length) {
|
||||
if (teams[1].players.some((p) => p.id === user.value.id)) return 1;
|
||||
}
|
||||
return 2;
|
||||
});
|
||||
|
||||
const checkBowData = () => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/match-detail?id=${data.value.id}`,
|
||||
url: `/pages/match-detail?battleId=${data.value.matchId}`,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<block v-if="data.mode === 1">
|
||||
<block v-if="data.mode <= 3">
|
||||
<view class="header-team" :style="{ marginTop: '25%' }">
|
||||
<image src="../static/battle-result.png" mode="widthFix" />
|
||||
<view class="header-solo" v-if="data.teamSize === 2">
|
||||
<view class="header-solo" v-if="data.mode === 1">
|
||||
<text
|
||||
:style="{
|
||||
background:
|
||||
data.winner === 1
|
||||
data.winTeam === 1
|
||||
? 'linear-gradient(270deg, #3597ff 0%, rgba(0,0,0,0) 100%);'
|
||||
: 'linear-gradient(270deg, #fd4444 0%, rgba(0, 0, 0, 0) 100%)',
|
||||
}"
|
||||
>{{ data.winner === 1 ? "蓝队" : "红队" }}获胜</text
|
||||
>{{ data.winTeam === 1 ? "蓝队" : "红队" }}获胜</text
|
||||
>
|
||||
<Avatar
|
||||
:size="32"
|
||||
:src="
|
||||
data.winner === 1
|
||||
? data.bluePlayers[0].avatar
|
||||
: data.redPlayers[0].avatar
|
||||
data.winTeam === 1
|
||||
? data.teams[1].players[0].avatar
|
||||
: data.teams[2].players[0].avatar
|
||||
"
|
||||
:borderColor="data.winner === 1 ? '#5FADFF' : '#FF5656'"
|
||||
:borderColor="data.winTeam === 1 ? '#5FADFF' : '#FF5656'"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-mvp" v-if="data.teamSize !== 2">
|
||||
<view class="header-mvp" v-if="data.mode === 2 || data.mode === 3">
|
||||
<image
|
||||
:src="`../static/${data.winner === 1 ? 'blue' : 'red'}-team-win.png`"
|
||||
:src="`../static/${data.winTeam === 1 ? 'blue' : 'red'}-team-win.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view
|
||||
:style="{
|
||||
transform: `translateY(50px) rotate(-${5 + data.mvps.length}deg)`,
|
||||
transform: `translateY(50px) rotate(-${
|
||||
5 + (data.mvp || []).length
|
||||
}deg)`,
|
||||
}"
|
||||
>
|
||||
<view v-if="data.mvps && data.mvps[0].totalRings">
|
||||
<view v-if="data.mvp && data.mvp.totalRings">
|
||||
<image src="../static/title-mvp.png" mode="widthFix" />
|
||||
<text
|
||||
>斩获<text
|
||||
@@ -153,22 +127,22 @@ const checkBowData = () => {
|
||||
margin: '0 3px',
|
||||
fontWeight: '600',
|
||||
}"
|
||||
>{{ data.mvps[0].totalRings }}</text
|
||||
>{{ data.mvp.totalRings }}</text
|
||||
>环</text
|
||||
>
|
||||
</view>
|
||||
<view v-if="data.mvps && data.mvps.length">
|
||||
<view v-for="(player, index) in data.mvps" :key="index">
|
||||
<view v-if="data.mvp && data.mvp.length">
|
||||
<view v-for="(player, index) in data.mvp" :key="index">
|
||||
<view class="team-avatar">
|
||||
<Avatar
|
||||
:src="player.avatar"
|
||||
:size="40"
|
||||
:borderColor="data.myTeam === 1 ? '#5fadff' : '#ff6060'"
|
||||
:borderColor="myTeam === 1 ? '#5fadff' : '#ff6060'"
|
||||
/>
|
||||
<text
|
||||
v-if="player.id === user.id"
|
||||
:style="{
|
||||
backgroundColor: data.myTeam === 1 ? '#5fadff' : '#ff6060',
|
||||
backgroundColor: myTeam === 1 ? '#5fadff' : '#ff6060',
|
||||
}"
|
||||
>自己</text
|
||||
>
|
||||
@@ -187,7 +161,7 @@ const checkBowData = () => {
|
||||
/>
|
||||
<image
|
||||
:src="
|
||||
getBattleResultTips(data.battleMode, data.mode, {
|
||||
getBattleResultTips(data.way, data.mode, {
|
||||
win: ifWin,
|
||||
})
|
||||
"
|
||||
@@ -196,7 +170,7 @@ const checkBowData = () => {
|
||||
/>
|
||||
</view>
|
||||
</block>
|
||||
<block v-if="data.mode === 2">
|
||||
<block v-else>
|
||||
<view class="header-melee">
|
||||
<view />
|
||||
<image src="../static/battle-result.png" mode="widthFix" />
|
||||
@@ -205,11 +179,11 @@ const checkBowData = () => {
|
||||
<view
|
||||
class="players"
|
||||
:style="{
|
||||
height: `${Math.max(data.playerStats.length > 5 ? '330' : '300')}px`,
|
||||
height: `${Math.max(players.length > 5 ? '330' : '300')}px`,
|
||||
}"
|
||||
>
|
||||
<view
|
||||
v-for="(player, index) in data.playerStats"
|
||||
v-for="(player, index) in players"
|
||||
:key="index"
|
||||
:style="{
|
||||
border: player.id === user.id ? '1px solid #B04630' : 'none',
|
||||
@@ -251,7 +225,9 @@ const checkBowData = () => {
|
||||
src="../static/champ3.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-if="index > 2" class="view-crown">{{ index + 1 }}</view>
|
||||
<view v-if="player.rank > 3" class="view-crown">{{
|
||||
player.rank
|
||||
}}</view>
|
||||
<Avatar
|
||||
:src="player.avatar"
|
||||
:size="36"
|
||||
@@ -259,10 +235,10 @@ const checkBowData = () => {
|
||||
/>
|
||||
<view class="player-title">
|
||||
<text class="truncate">{{ player.name }}</text>
|
||||
<text>{{ getLvlName(player.totalScore) }}</text>
|
||||
<text>{{ getLvlName(player.rank_lvl) }}</text>
|
||||
</view>
|
||||
<text
|
||||
><text :style="{ color: '#fff' }">{{ player.totalRings }}</text>
|
||||
><text :style="{ color: '#fff' }">{{ player.totalRing }}</text>
|
||||
环</text
|
||||
>
|
||||
</view>
|
||||
@@ -270,36 +246,36 @@ const checkBowData = () => {
|
||||
</block>
|
||||
<view
|
||||
class="battle-e"
|
||||
:style="{ marginTop: data.mode === 2 ? '20px' : '20vw' }"
|
||||
:style="{ marginTop: data.mode > 3 ? '20px' : '20vw' }"
|
||||
>
|
||||
<image src="../static/row-yellow-bg.png" mode="widthFix" />
|
||||
<view class="team-avatar">
|
||||
<Avatar
|
||||
:src="user.avatar"
|
||||
:size="40"
|
||||
:borderColor="data.myTeam === 1 ? '#5fadff' : '#ff6060'"
|
||||
:borderColor="myTeam === 1 ? '#5fadff' : '#ff6060'"
|
||||
/>
|
||||
<text
|
||||
:style="{ backgroundColor: '#5fadff' }"
|
||||
v-if="data.mode === 1 && data.myTeam === 1"
|
||||
v-if="data.mode <= 3 && myTeam === 1"
|
||||
>蓝队</text
|
||||
>
|
||||
<text
|
||||
:style="{ backgroundColor: '#ff6060' }"
|
||||
v-if="data.mode === 1 && data.myTeam === 0"
|
||||
v-if="data.mode <= 3 && myTeam === 2"
|
||||
>红队</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="data.battleMode === 1">
|
||||
<text v-if="data.way === 1">
|
||||
你的经验 {{ totalPoints > 0 ? "+" + totalPoints : totalPoints }}
|
||||
</text>
|
||||
<text v-if="data.battleMode === 2">
|
||||
<text v-if="data.way === 2">
|
||||
你的积分 {{ totalPoints > 0 ? "+" + totalPoints : totalPoints }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="data.mode === 2" class="description">
|
||||
<text v-if="data.mode > 3" class="description">
|
||||
{{
|
||||
getBattleResultTips(data.battleMode, data.mode, {
|
||||
getBattleResultTips(data.way, data.mode, {
|
||||
win: ifWin,
|
||||
score: totalPoints,
|
||||
rank,
|
||||
@@ -308,7 +284,7 @@ const checkBowData = () => {
|
||||
</text>
|
||||
<view class="op-btn">
|
||||
<view @click="checkBowData">查看成绩</view>
|
||||
<view @click="exit">退出</view>
|
||||
<view @click="exit">返回</view>
|
||||
</view>
|
||||
<UserUpgrade />
|
||||
</view>
|
||||
@@ -418,6 +394,7 @@ const checkBowData = () => {
|
||||
border-radius: 20px;
|
||||
padding: 10px 0;
|
||||
text-align: center;
|
||||
color: #000;
|
||||
}
|
||||
.op-btn > view:last-child {
|
||||
color: #fff;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,273 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import UserHeader from "@/components/UserHeader.vue";
|
||||
import { createOrderAPI, getHomeData, getVIPDescAPI } from "@/apis";
|
||||
import { formatTimestamp } from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, config } = storeToRefs(store);
|
||||
const { updateUser } = store;
|
||||
|
||||
const selectedVIP = ref(0);
|
||||
const showModal = ref(false);
|
||||
const lastDate = ref(user.value.expiredAt);
|
||||
const refreshing = ref(false);
|
||||
const timer = ref(null);
|
||||
const richContent = ref("");
|
||||
|
||||
const onPay = async () => {
|
||||
if (!user.value.id) {
|
||||
showModal.value = true;
|
||||
} else if (config.value.vipMenus[selectedVIP.value]) {
|
||||
if (config.value.vipMenus[selectedVIP.value].id) {
|
||||
const result = await createOrderAPI(
|
||||
config.value.vipMenus[selectedVIP.value].id
|
||||
);
|
||||
if (!result.pay) return;
|
||||
const params = result.pay.order.jsApi.params;
|
||||
if (params) {
|
||||
wx.requestPayment({
|
||||
timeStamp: params.timeStamp, // 时间戳
|
||||
nonceStr: params.nonceStr, // 随机字符串
|
||||
package: params.package, // 统一下单接口返回的 prepay_id 参数值,格式:prepay_id=***
|
||||
paySign: params.paySign, // 签名
|
||||
signType: "RSA", // 签名类型,默认为RSA
|
||||
async success(res) {
|
||||
uni.showToast({
|
||||
title: "支付成功",
|
||||
icon: "none",
|
||||
});
|
||||
timer.value = setInterval(async () => {
|
||||
refreshing.value = true;
|
||||
const result = await getHomeData();
|
||||
if (result.user.expiredAt > lastDate.value) {
|
||||
refreshing.value = false;
|
||||
if (result.user) updateUser(result.user);
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
fail(res) {
|
||||
console.log("pay error", res);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const result = await getVIPDescAPI();
|
||||
richContent.value = result.describe;
|
||||
});
|
||||
|
||||
const toOrderPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/orders",
|
||||
});
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="会员说明">
|
||||
<view :style="{ width: '100%', height: '100%' }">
|
||||
<view v-if="user.id" class="header">
|
||||
<view>
|
||||
<Avatar :src="user.avatar" :size="35" />
|
||||
<text class="truncate">{{ user.nickName }}</text>
|
||||
<image
|
||||
class="user-name-image"
|
||||
src="../static/vip1.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<block v-if="refreshing">
|
||||
<image
|
||||
src="../static/btn-loading.png"
|
||||
mode="widthFix"
|
||||
class="loading"
|
||||
/>
|
||||
</block>
|
||||
<block v-else>
|
||||
<text v-if="user.expiredAt">
|
||||
{{ formatTimestamp(user.expiredAt) }} 到期
|
||||
</text>
|
||||
</block>
|
||||
</view>
|
||||
<view
|
||||
class="container"
|
||||
:style="{ height: !user.id ? '100%' : 'calc(100% - 62px)' }"
|
||||
>
|
||||
<view class="content vip-content">
|
||||
<view class="title-bar">
|
||||
<view />
|
||||
<text>VIP 介绍</text>
|
||||
</view>
|
||||
<view :style="{ marginTop: '10rpx' }">
|
||||
<rich-text :nodes="richContent" />
|
||||
<!-- <text
|
||||
>射灵星球VIP服务,为全球弓箭手提供约战、段位评级、实时排位赛、智能教练点评等专属特权。会员可在酷帅的真实射箭运动中,同步享受在线竞技的乐趣,还能找到志同道合的伙伴,并获得新鲜的功能体验和持续升级的系统。
|
||||
</text>
|
||||
<text
|
||||
>所有新注册用户,我们都会默认赠送6个月超长会员。到期之后可续费,单月10元,年度VIP100元。我们鼓励每一位弓箭手长期坚持练习这项运动,在对战的世界中尽情驰骋,不断挑战自我,创造属于自己的辉煌战绩。
|
||||
</text>
|
||||
<text
|
||||
>VIP会员还将获得专属客服支持。当您在游戏中遇到任何问题,无论是技术故障、规则疑问还是其他需要帮助的情况,都可联系我们的VIP专属客服团队。他们将提供全年不间断的优质服务,确保您的对战体验不受影响。
|
||||
</text>
|
||||
<text>期待您的加入!</text> -->
|
||||
</view>
|
||||
</view>
|
||||
<view class="content">
|
||||
<view class="title-bar">
|
||||
<view />
|
||||
<text>会员续费</text>
|
||||
</view>
|
||||
<view class="vip-items">
|
||||
<view
|
||||
v-for="(item, index) in config.vipMenus || []"
|
||||
:key="index"
|
||||
:style="{
|
||||
color: selectedVIP === index ? '#fff' : '#333333',
|
||||
borderColor: selectedVIP === index ? '#FF7D57' : '#eee',
|
||||
background:
|
||||
selectedVIP === index
|
||||
? '#FF7D57'
|
||||
: 'linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%)',
|
||||
}"
|
||||
@click="() => (selectedVIP = index)"
|
||||
>
|
||||
{{ item.name }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<SButton :onClick="onPay">支付</SButton>
|
||||
<SModal :show="showModal" :onClose="() => (showModal = false)">
|
||||
<Signin :onClose="() => (showModal = false)" />
|
||||
</SModal>
|
||||
<view class="my-orders" v-if="user.id">
|
||||
<view @click="toOrderPage">
|
||||
<text>我的订单</text>
|
||||
<image src="../static/enter-arrow-blue.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
width: calc(100% - 30px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
padding: 15px;
|
||||
padding-top: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.header > view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header > view > text {
|
||||
margin-left: 10px;
|
||||
max-width: 120px;
|
||||
text-align: left;
|
||||
}
|
||||
.header > view > image {
|
||||
margin-left: 5px;
|
||||
width: 20px;
|
||||
}
|
||||
.header > text:nth-child(2) {
|
||||
color: #fed847;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
background-color: #f5f5f5;
|
||||
padding-top: 10px;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
padding: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.title-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #000;
|
||||
}
|
||||
.title-bar > view:first-child {
|
||||
width: 5px;
|
||||
height: 15px;
|
||||
border-radius: 10px;
|
||||
background-color: #fed847;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.content > view:nth-child(2) {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
.vip-items {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 23.5%);
|
||||
padding: 10px;
|
||||
row-gap: 5%;
|
||||
column-gap: 2%;
|
||||
}
|
||||
.vip-items > view {
|
||||
border: 1px solid #eee;
|
||||
padding: 12px 0;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
font-size: 27rpx;
|
||||
}
|
||||
.vip-content {
|
||||
max-height: 62%;
|
||||
}
|
||||
.vip-content > view:nth-child(2) {
|
||||
overflow: auto;
|
||||
}
|
||||
.vip-content > view:nth-child(2)::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
.my-orders {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: #39a8ff;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.my-orders > view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.my-orders > view > image {
|
||||
width: 15px;
|
||||
}
|
||||
.loading {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
transition: all 0.3s ease;
|
||||
background-blend-mode: darken;
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
</style>
|
||||
116
src/pages/calibration.vue
Normal file
116
src/pages/calibration.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import Container from "@/components/Container.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
|
||||
import { laserAimAPI, laserCloseAPI } from "@/apis";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
// import audioManager from "@/audioManager";
|
||||
|
||||
const guides = [
|
||||
{
|
||||
title: "箭头面向靶子",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5wscg7hrfo.png",
|
||||
},
|
||||
{
|
||||
title: "摆出拉弓姿势",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5b7ljrhx3o.png",
|
||||
},
|
||||
{
|
||||
title: "调整瞄准器",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-29/dduexjgrcxf9wjaiv4.png",
|
||||
},
|
||||
];
|
||||
|
||||
const done = ref(true);
|
||||
|
||||
const onComplete = async () => {
|
||||
await laserCloseAPI();
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.Calibration) {
|
||||
done.value = true;
|
||||
uni.setStorageSync("calibration", true);
|
||||
// audioManager.play("激光已校准");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
await laserAimAPI();
|
||||
});
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
await laserCloseAPI();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="校准智能弓">
|
||||
<view class="container">
|
||||
<view v-for="(guide, index) in guides" :key="guide.title" class="guide">
|
||||
<view>
|
||||
<text>{{ index + 1 }}</text>
|
||||
<text>{{ guide.title }}</text>
|
||||
</view>
|
||||
<image :src="guide.src" mode="widthFix" />
|
||||
</view>
|
||||
<text>请完成以上步骤校准智能弓</text>
|
||||
<SButton
|
||||
:onClick="onComplete"
|
||||
width="60vw"
|
||||
:rounded="40"
|
||||
:disabled="!done"
|
||||
>
|
||||
我已校准
|
||||
</SButton>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
.guide > view {
|
||||
width: 100%;
|
||||
margin: 25rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.guide > view > text:first-child {
|
||||
font-size: 24rpx;
|
||||
background: #e89024;
|
||||
border-radius: 50%;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
line-height: 32rpx;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
.guide > image {
|
||||
width: 630rpx;
|
||||
height: 250rpx;
|
||||
}
|
||||
.container > text {
|
||||
font-size: 24rpx;
|
||||
color: #fff9;
|
||||
margin: 30rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -2,24 +2,21 @@
|
||||
import { ref, onMounted } from "vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
|
||||
import { capsuleHeight } from "@/util";
|
||||
|
||||
const images = [
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmf6yitekatwe.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmi475gqdtrvx.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmgy8ej5wuap5.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmg6y7nveaadv.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmfhqew0xhy6i.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmhs38abrqfyp.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmgnj4rttovk3.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmg68a8mezgzx.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-10-14/ddht51a3hiyw7ueli4.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_01.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_02.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_03.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_04.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_05.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_06.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_07.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_08.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_09.jpg",
|
||||
];
|
||||
|
||||
const addBg = ref("");
|
||||
const capsuleHeight = ref(0);
|
||||
onMounted(async () => {
|
||||
const menuBtnInfo = uni.getMenuButtonBoundingClientRect();
|
||||
capsuleHeight.value = menuBtnInfo.top - 9;
|
||||
});
|
||||
const addBg = ref(false);
|
||||
|
||||
const onScrollView = (e) => {
|
||||
addBg.value = e.detail.scrollTop > 100;
|
||||
@@ -35,9 +32,8 @@ const onScrollView = (e) => {
|
||||
}"
|
||||
>
|
||||
<image
|
||||
v-if="addBg"
|
||||
class="bg-image"
|
||||
src="../static/app-bg.png"
|
||||
:style="{ opacity: addBg ? 1 : 0 }"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<navigator open-type="navigateBack">
|
||||
@@ -46,12 +42,17 @@ const onScrollView = (e) => {
|
||||
<text
|
||||
:style="{ opacity: addBg ? 1 : 0, color: '#fff', fontWeight: 'bold' }"
|
||||
>
|
||||
本赛季排行榜
|
||||
</text>
|
||||
</view>
|
||||
<scroll-view scroll-y @scroll="onScrollView" :style="{ height: '100vh' }">
|
||||
<view class="images">
|
||||
<image v-for="src in images" :key="src" :src="src" mode="widthFix" show-menu-by-longpress />
|
||||
<image
|
||||
v-for="src in images"
|
||||
:key="src"
|
||||
:src="src"
|
||||
mode="widthFix"
|
||||
show-menu-by-longpress
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -71,7 +72,6 @@ const onScrollView = (e) => {
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -82,12 +82,19 @@ const onScrollView = (e) => {
|
||||
margin-top: 5px;
|
||||
position: relative;
|
||||
}
|
||||
.bg-image {
|
||||
.header > image:first-child {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
.header > text {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
transition: all 0.5s ease;
|
||||
position: relative;
|
||||
}
|
||||
.images {
|
||||
display: flex;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import Guide from "@/components/Guide.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import Swiper from "@/components/Swiper.vue";
|
||||
@@ -12,9 +12,18 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import BowPower from "@/components/BowPower.vue";
|
||||
import TestDistance from "@/components/TestDistance.vue";
|
||||
import BubbleTip from "@/components/BubbleTip.vue";
|
||||
import { createPractiseAPI } from "@/apis";
|
||||
import { generateCanvasImage } from "@/util";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import audioManager from "@/audioManager";
|
||||
import {
|
||||
createPractiseAPI,
|
||||
startPractiseAPI,
|
||||
endPractiseAPI,
|
||||
getPractiseAPI,
|
||||
laserAimAPI,
|
||||
laserCloseAPI,
|
||||
} from "@/apis";
|
||||
import { sharePractiseData } from "@/canvas";
|
||||
import { wxShare, debounce } from "@/util";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
@@ -25,6 +34,7 @@ const total = 12;
|
||||
const stepButtonTexts = [
|
||||
"开始",
|
||||
"进入下一个任务",
|
||||
"我已校准",
|
||||
"进入下一个任务",
|
||||
"我准备好了,开始",
|
||||
"",
|
||||
@@ -33,172 +43,238 @@ const stepButtonTexts = [
|
||||
const title = ref("新手试炼场");
|
||||
const start = ref(false);
|
||||
const practiseResult = ref({});
|
||||
const power = ref(0);
|
||||
const btnDisabled = ref(false);
|
||||
const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const laserActive = ref(false);
|
||||
const guideSwiperIndex = ref(0);
|
||||
|
||||
const guideImages = [
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68bs7z5elwvw7.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68qmi7grgreen.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68hgrw1ip4wae.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x684x8zmfrmbla.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x67sding7fodnk.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68mpug7cac4yt.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68my783mlmgxv.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68p48ylzirtb0.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/target.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwdpgwt9e6du.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wvv9sw4zioqk.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0ww3khaycallu.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wtkcvaxxv0s8.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wry5tw7ltmxr.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wu3kcdrwzwpd.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwr6hfjhyfn5.png",
|
||||
];
|
||||
|
||||
const calibrationGuides = [
|
||||
{
|
||||
title: "箭头面向靶子",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5wscg7hrfo.png",
|
||||
},
|
||||
{
|
||||
title: "摆出拉弓姿势",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5b7ljrhx3o.png",
|
||||
},
|
||||
{
|
||||
title: "调整瞄准器",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-29/dduexjgrcxf9wjaiv4.png",
|
||||
},
|
||||
];
|
||||
|
||||
const onSwiperIndexChange = (index) => {
|
||||
if (index + 1 === guideImages.length) {
|
||||
showGuide.value = true;
|
||||
}
|
||||
guideSwiperIndex.value = index;
|
||||
showGuide.value = index + 1 === guideImages.length;
|
||||
};
|
||||
|
||||
const isGuideLastImage = computed(
|
||||
() => guideSwiperIndex.value + 1 === guideImages.length
|
||||
);
|
||||
|
||||
const currentStepButtonText = computed(() => {
|
||||
if (step.value === 1 && isGuideLastImage.value) return "去校准智能弓";
|
||||
return stepButtonTexts[step.value];
|
||||
});
|
||||
|
||||
const openCalibrationLaser = async () => {
|
||||
if (laserActive.value) return;
|
||||
await laserAimAPI();
|
||||
laserActive.value = true;
|
||||
};
|
||||
|
||||
const closeCalibrationLaser = async () => {
|
||||
if (!laserActive.value) return;
|
||||
await laserCloseAPI();
|
||||
laserActive.value = false;
|
||||
};
|
||||
|
||||
const createPractise = async (arrows) => {
|
||||
const result = await createPractiseAPI(arrows);
|
||||
const result = await createPractiseAPI(arrows, 1);
|
||||
if (result) practiseId.value = result.id;
|
||||
};
|
||||
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
if (scores.value.length < total) {
|
||||
scores.value.push(msg.target);
|
||||
}
|
||||
power.value = msg.target.battery;
|
||||
// if (step.value === 2 && msg.target.dst / 100 >= 5) {
|
||||
const onOver = async () => {
|
||||
practiseResult.value = await getPractiseAPI(practiseId.value);
|
||||
start.value = false;
|
||||
};
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
scores.value = msg.details;
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
setTimeout(onOver, 1500);
|
||||
} else if (msg.type === MESSAGETYPESV2.TestDistance && step.value === 3) {
|
||||
if (msg.shootData.distance / 100 >= 5) {
|
||||
audioManager.play("距离合格");
|
||||
btnDisabled.value = false;
|
||||
showGuide.value = true;
|
||||
// }
|
||||
}
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMePracticeID) {
|
||||
if (practiseId.value && practiseId.value === msg.practice.id) {
|
||||
setTimeout(() => {
|
||||
start.value = false;
|
||||
practiseResult.value = {
|
||||
...msg.practice,
|
||||
arrows: JSON.parse(msg.practice.arrows),
|
||||
lvl: msg.lvl,
|
||||
};
|
||||
generateCanvasImage(
|
||||
"shareCanvas",
|
||||
1,
|
||||
user.value,
|
||||
practiseResult.value
|
||||
);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else audioManager.play("距离不足");
|
||||
}
|
||||
// messages.forEach((msg) => {
|
||||
// if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
// if (step.value === 2 && msg.target.dst / 100 >= 5) {
|
||||
// btnDisabled.value = false;
|
||||
// showGuide.value = true;
|
||||
// } else if (scores.value.length < total) {
|
||||
// scores.value.push(msg.target);
|
||||
// }
|
||||
// if (scores.value.length === total) {
|
||||
// setTimeout(onOver, 1500);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
const onClickShare = debounce(async () => {
|
||||
await sharePractiseData("shareCanvas", 1, user.value, practiseResult.value);
|
||||
await wxShare("shareCanvas");
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: true,
|
||||
});
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("share-image", onClickShare);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
onBeforeUnmount(async () => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: false,
|
||||
});
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("share-image", onClickShare);
|
||||
await closeCalibrationLaser();
|
||||
audioManager.stopAll();
|
||||
endPractiseAPI();
|
||||
});
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step.value === 0) {
|
||||
step.value = 1;
|
||||
title.value = "-凹造型";
|
||||
title.value = "-箭前准备";
|
||||
} else if (step.value === 1) {
|
||||
if (!isGuideLastImage.value) {
|
||||
guideSwiperIndex.value += 1;
|
||||
showGuide.value = guideSwiperIndex.value + 1 === guideImages.length;
|
||||
return;
|
||||
}
|
||||
showGuide.value = false;
|
||||
step.value = 2;
|
||||
// title.value = "-校准智能弓";
|
||||
await openCalibrationLaser();
|
||||
} else if (step.value === 2) {
|
||||
await closeCalibrationLaser();
|
||||
showGuide.value = false;
|
||||
btnDisabled.value = true;
|
||||
step.value = 2;
|
||||
title.value = "-感知距离";
|
||||
} else if (step.value === 2) {
|
||||
showGuide.value = false;
|
||||
step.value = 3;
|
||||
title.value = "-小试牛刀";
|
||||
title.value = "-感知距离";
|
||||
const result = await createPractiseAPI(total, 120);
|
||||
if (result) practiseId.value = result.id;
|
||||
} else if (step.value === 3) {
|
||||
title.value = "小试牛刀";
|
||||
await createPractise(total);
|
||||
scores.value = [];
|
||||
showGuide.value = false;
|
||||
step.value = 4;
|
||||
title.value = "-小试牛刀";
|
||||
} else if (step.value === 4) {
|
||||
title.value = "小试牛刀";
|
||||
await startPractiseAPI();
|
||||
scores.value = [];
|
||||
step.value = 5;
|
||||
start.value = true;
|
||||
setTimeout(() => {
|
||||
uni.$emit("play-sound", "请开始射击");
|
||||
}, 300);
|
||||
} else if (step.value === 5) {
|
||||
} else if (step.value === 6) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
if (
|
||||
practiseResult.value.arrows &&
|
||||
practiseResult.value.arrows.length === total
|
||||
) {
|
||||
const onClose = async () => {
|
||||
const validArrows = (practiseResult.value.details || []).filter(
|
||||
(a) => a.x !== -30 && a.y !== -30
|
||||
);
|
||||
if (validArrows.length === total) {
|
||||
setTimeout(() => {
|
||||
practiseResult.value = {};
|
||||
showGuide.value = false;
|
||||
step.value = 5;
|
||||
step.value = 6;
|
||||
}, 500);
|
||||
} else {
|
||||
practiseResult.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
step.value = 3;
|
||||
step.value = 4;
|
||||
const result = await createPractiseAPI(total, 120);
|
||||
if (result) practiseId.value = result.id;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="1" :title="title">
|
||||
<Container :bgType="1" :title="title" :showBottom="step !== 5">
|
||||
<view class="container">
|
||||
<Guide
|
||||
v-if="step !== 4"
|
||||
v-if="step !== 5"
|
||||
:type="
|
||||
step === 2
|
||||
step === 3
|
||||
? 2
|
||||
: step === 5 || (step === 0 && user.nickName.length > 6)
|
||||
: step === 6 || (step === 0 && user.nickName.length > 6)
|
||||
? 1
|
||||
: 0
|
||||
"
|
||||
>
|
||||
<text v-if="step === 0">
|
||||
<text
|
||||
v-if="step === 0"
|
||||
:style="{
|
||||
fontSize: '28rpx',
|
||||
marginTop: user.nickName.length > 6 ? '-10rpx' : '0',
|
||||
}"
|
||||
>
|
||||
hi,<text :style="{ color: '#fed847' }">{{ user.nickName }}</text>
|
||||
,这是新人必刷小任务,0基础小白也能快速掌握弓箭技巧和游戏规则哦~:)
|
||||
</text>
|
||||
<text v-if="step === 1"
|
||||
>这是我们人帅技高的高教练。首先,请按教练示范,尝试自己去做这些动作和手势吧。</text
|
||||
<text v-if="step === 1" :style="{ fontSize: '28rpx' }"
|
||||
>这位就是人帅技高的高教练!接下来请跟随教练指引,做好射箭前期准备。</text
|
||||
>
|
||||
<view v-if="step === 2">
|
||||
<view :style="{ display: 'flex', flexDirection: 'column' }">
|
||||
<text :style="{ color: '#fed847' }">你知道5米射程有多远吗?</text>
|
||||
<text>
|
||||
在我们的排位赛中,射程小于5米的成绩无效、哦!建议平时练习距离至少5米。现在,来边射箭边调整你的站位点吧!
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="step === 2" :style="{ fontSize: '28rpx' }"
|
||||
>请按下方步骤完成智能弓校准,让瞄准器和靶子保持对齐。</text
|
||||
>
|
||||
<view
|
||||
class="guide-tips"
|
||||
:style="{ marginTop: '8rpx' }"
|
||||
v-if="step === 3"
|
||||
>
|
||||
<text>你知道5米射程有多远吗?</text>
|
||||
<text>
|
||||
在我们的排位赛中,射程小于5米的成绩无效、哦!建议平时练习距离至少5米。现在,来边射箭边调整你的站位点吧!
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="step === 3">
|
||||
<view :style="{ display: 'flex', flexDirection: 'column' }">
|
||||
<text :style="{ color: '#fed847' }">一切准备就绪</text>
|
||||
<text>试着完成一个真正的弓箭手任务吧!</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="step === 5">
|
||||
<view
|
||||
:style="{ display: 'flex', flexDirection: 'column', marginTop: 20 }"
|
||||
<view class="guide-tips" v-if="step === 4">
|
||||
<text>一切准备就绪</text>
|
||||
<text :style="{ fontSize: '28rpx' }"
|
||||
>试着完成一个真正的弓箭手任务吧!</text
|
||||
>
|
||||
</view>
|
||||
<view class="guide-tips" v-if="step === 6">
|
||||
<text>新手试炼场通关啦,优秀!</text>
|
||||
<text :style="{ fontSize: '28rpx' }"
|
||||
>反曲弓运动基本知识和射灵世界系统规则你已Get,是不是挺容易呀:)</text
|
||||
>
|
||||
<text :style="{ color: '#fed847' }">新手试炼场通关啦,优秀!</text>
|
||||
<text
|
||||
>反曲弓运动基本知识和射灵世界系统规则你已Get,是不是挺容易呀:)</text
|
||||
>
|
||||
<text :style="{ opacity: 0 }">新手试炼场通关啦,优秀!</text>
|
||||
</view>
|
||||
</view>
|
||||
</Guide>
|
||||
<image
|
||||
@@ -208,64 +284,86 @@ const onClose = () => {
|
||||
v-if="step === 0"
|
||||
/>
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-07-01/db0ehpzl8hfzeswfrf.png"
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-11-17/deas80ef1sf9td0leq.png"
|
||||
class="try-tip"
|
||||
mode="widthFix"
|
||||
v-if="step === 3"
|
||||
v-if="step === 4"
|
||||
/>
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-07-01/db0ehpz9lav58g5drl.png"
|
||||
class="try-tip"
|
||||
mode="widthFix"
|
||||
v-if="step === 5"
|
||||
v-if="step === 6"
|
||||
/>
|
||||
<view style="height: 570px" v-if="step === 1">
|
||||
<Swiper :onChange="onSwiperIndexChange" :data="guideImages" />
|
||||
<Swiper
|
||||
:current="guideSwiperIndex"
|
||||
:onChange="onSwiperIndexChange"
|
||||
:data="guideImages"
|
||||
/>
|
||||
</view>
|
||||
<ShootProgress v-if="step === 4" tips="请开始连续射箭" :start="start" />
|
||||
<TestDistance v-if="step === 2" :guide="false" />
|
||||
<view class="calibration-container" v-if="step === 2">
|
||||
<view
|
||||
v-for="(guide, index) in calibrationGuides"
|
||||
:key="guide.title"
|
||||
class="calibration-guide"
|
||||
>
|
||||
<view>
|
||||
<text>{{ index + 1 }}</text>
|
||||
<text>{{ guide.title }}</text>
|
||||
</view>
|
||||
<image :src="guide.src" mode="widthFix" />
|
||||
</view>
|
||||
<text>请完成以上步骤校准智能弓</text>
|
||||
</view>
|
||||
<ShootProgress v-if="step === 5" tips="请开始连续射箭" :start="start" />
|
||||
<TestDistance v-if="step === 3" :guide="false" />
|
||||
<view
|
||||
class="user-row"
|
||||
v-if="step === 4"
|
||||
:style="{ marginBottom: step === 2 ? '40px' : '0' }"
|
||||
v-if="step === 5"
|
||||
:style="{ marginBottom: '0' }"
|
||||
>
|
||||
<Avatar :src="user.avatar" :size="35" />
|
||||
<BowPower :power="power" />
|
||||
<BowPower />
|
||||
</view>
|
||||
<BowTarget
|
||||
v-if="step === 4"
|
||||
:currentRound="step === 4 ? scores.length : 0"
|
||||
:totalRound="step === 4 ? total : 0"
|
||||
v-if="step === 5"
|
||||
:currentRound="step === 5 ? scores.length : 0"
|
||||
:totalRound="step === 5 ? total : 0"
|
||||
:scores="scores"
|
||||
/>
|
||||
<ScorePanel
|
||||
v-if="step === 4"
|
||||
v-if="step === 5"
|
||||
:total="total"
|
||||
:rowCount="6"
|
||||
:scores="scores.map((s) => s.ring)"
|
||||
:arrows="scores"
|
||||
/>
|
||||
<ScoreResult
|
||||
v-if="practiseResult.arrows"
|
||||
v-if="practiseResult.details"
|
||||
:rowCount="6"
|
||||
:total="total"
|
||||
:onClose="onClose"
|
||||
:result="practiseResult"
|
||||
:tipSrc="`../static/${
|
||||
practiseResult.arrows.length < total ? 'un' : ''
|
||||
practiseResult.details.filter(
|
||||
(arrow) => arrow.x !== -30 && arrow.y !== -30
|
||||
).length < total
|
||||
? 'un'
|
||||
: ''
|
||||
}finish-tip.png`"
|
||||
/>
|
||||
<canvas class="share-canvas" canvas-id="shareCanvas"></canvas>
|
||||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||||
</view>
|
||||
<view :style="{ marginBottom: '20px' }">
|
||||
<SButton v-if="step !== 4" :onClick="nextStep" :disabled="btnDisabled">
|
||||
<template #bottom>
|
||||
<SButton :onClick="nextStep" :disabled="btnDisabled">
|
||||
<BubbleTip v-if="showGuide" :type="step === 1 ? 'long' : 'short'">
|
||||
<text :style="{ transform: 'translateY(-18rpx)' }">{{
|
||||
step === 1 ? "学会了,我摆得比教练还帅" : "我找到合适的点位了"
|
||||
}}</text>
|
||||
</BubbleTip>
|
||||
{{ stepButtonTexts[step] }}
|
||||
{{ currentStepButtonText }}
|
||||
</SButton>
|
||||
</view>
|
||||
</template>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
@@ -277,4 +375,43 @@ const onClose = () => {
|
||||
width: calc(100% - 20px);
|
||||
margin: 0 10px;
|
||||
}
|
||||
.calibration-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.calibration-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
.calibration-guide > view {
|
||||
width: 100%;
|
||||
margin: 25rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.calibration-guide > view > text:first-child {
|
||||
font-size: 24rpx;
|
||||
background: #e89024;
|
||||
border-radius: 50%;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
line-height: 32rpx;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
.calibration-guide > image {
|
||||
width: 630rpx;
|
||||
height: 250rpx;
|
||||
}
|
||||
.calibration-container > text {
|
||||
font-size: 24rpx;
|
||||
color: #fff9;
|
||||
margin: 30rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
1172
src/pages/friend-battle-result.vue
Normal file
1172
src/pages/friend-battle-result.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,86 +1,126 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Guide from "@/components/Guide.vue";
|
||||
import GuideTwo from "@/components/GuideTwo.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import CreateRoom from "@/components/CreateRoom.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import { getRoomAPI, joinRoomAPI, isGamingAPI, getBattleDataAPI } from "@/apis";
|
||||
|
||||
import { getRoomAPI, joinRoomAPI, getBattleDataAPI } from "@/apis";
|
||||
import { debounce, canEenter } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
import { debounce } from "@/util";
|
||||
const { user, device, online, game } = storeToRefs(store);
|
||||
|
||||
const showModal = ref(false);
|
||||
const showSignin = ref(false);
|
||||
const warnning = ref("");
|
||||
const roomNumber = ref("");
|
||||
const data = ref({});
|
||||
const roomID = ref("");
|
||||
const loading = ref(false);
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||
|
||||
const enterRoom = debounce(async () => {
|
||||
const isGaming = await isGamingAPI();
|
||||
if (isGaming) {
|
||||
const enterRoom = debounce(async (number) => {
|
||||
if (loading.value) return;
|
||||
if (!canEenter(user.value, device.value, online.value)) return;
|
||||
if (game.value.inBattle) {
|
||||
uni.$showHint(1);
|
||||
return;
|
||||
}
|
||||
if (!roomNumber.value) {
|
||||
if (!number) {
|
||||
warnning.value = "请输入房间号";
|
||||
showModal.value = true;
|
||||
} else {
|
||||
const room = await getRoomAPI(roomNumber.value);
|
||||
if (room.number) {
|
||||
const alreadyIn = room.members.find(
|
||||
(item) => item.userInfo.id === user.value.id
|
||||
);
|
||||
if (!alreadyIn) {
|
||||
const result = await joinRoomAPI(roomNumber.value);
|
||||
if (result.full) {
|
||||
warnning.value = "房间已满员";
|
||||
showModal.value = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
roomNumber.value = "";
|
||||
showModal.value = false;
|
||||
uni.navigateTo({
|
||||
url: `/pages/battle-room?roomNumber=${room.number}`,
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const room = await getRoomAPI(number);
|
||||
if (!room.number) {
|
||||
warnning.value = room.started ? "该房间对战已开始,无法加入" : "查无此房";
|
||||
showModal.value = true;
|
||||
return;
|
||||
}
|
||||
const alreadyIn = room.members.find(
|
||||
(item) => item.userInfo.id === user.value.id
|
||||
);
|
||||
if (!alreadyIn) {
|
||||
const result = await joinRoomAPI(number);
|
||||
if (result.full) {
|
||||
warnning.value = "房间已满员";
|
||||
showModal.value = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
loading.value = true;
|
||||
uni.navigateTo({
|
||||
url: "/pages/battle-room?roomNumber=" + number,
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const onCreateRoom = async () => {
|
||||
const isGaming = await isGamingAPI();
|
||||
if (isGaming) {
|
||||
uni.$showHint(1);
|
||||
return;
|
||||
}
|
||||
if (!canEenter(user.value, device.value, online.value)) return;
|
||||
warnning.value = "";
|
||||
showModal.value = true;
|
||||
};
|
||||
const onSignin = () => {
|
||||
if (roomID.value && user.value.id) enterRoom(roomID.value);
|
||||
showSignin.value = false;
|
||||
};
|
||||
|
||||
/** 跳转到我的战绩页面,默认展示「好友约战」tab */
|
||||
const goMyRecord = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/my-growth?tab=1',
|
||||
});
|
||||
};
|
||||
onShow(async () => {
|
||||
const result = await getBattleDataAPI();
|
||||
data.value = result;
|
||||
if (user.value.id) {
|
||||
const result = await getBattleDataAPI();
|
||||
data.value = result;
|
||||
}
|
||||
});
|
||||
onLoad(async (options) => {
|
||||
if (options.roomID) {
|
||||
roomID.value = options.roomID;
|
||||
if (user.value.id) enterRoom(options.roomID);
|
||||
else showSignin.value = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="好友约战" :showBackToGame="true">
|
||||
<view :style="{ width: '100%' }">
|
||||
<Guide>
|
||||
<view :style="{ width: '100%', height: '100%' }">
|
||||
<GuideTwo>
|
||||
<view class="guide-tips">
|
||||
<text>约上朋友开几局,欢乐多,不寂寞</text>
|
||||
<text>一起练升级更快,早日加入全国排位赛!</text>
|
||||
<text class="guide-tips__main">约上朋友开几局,欢乐多,不寂寞</text>
|
||||
<text class="guide-tips__sub">一起练升级更快,早日加入全国排位赛!</text>
|
||||
</view>
|
||||
</Guide>
|
||||
</GuideTwo>
|
||||
<view class="my-data">
|
||||
<view>
|
||||
<Avatar :rankLvl="user.rankLvl" :src="user.avatar" :size="30" />
|
||||
<text class="truncate">{{ user.nickName }}</text>
|
||||
<view
|
||||
:class="[
|
||||
'member-nickname',
|
||||
isVip ? 'member-nickname--vip' : '',
|
||||
isSVip ? 'member-nickname--svip' : '',
|
||||
]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||
<text v-if="isSVip" class="member-nickname__shine">{{
|
||||
user.nickName
|
||||
}}</text>
|
||||
</view>
|
||||
<text class="my-record-btn" @click="goMyRecord">我的战绩</text>
|
||||
</view>
|
||||
<view>
|
||||
<view>
|
||||
@@ -100,16 +140,9 @@ onShow(async () => {
|
||||
<view>
|
||||
<view class="stars">
|
||||
<block v-for="i in 5" :key="i">
|
||||
<image
|
||||
v-if="data.totalWinningRate >= i * 0.2"
|
||||
src="../static/star-full.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-else-if="data.totalWinningRate >= (i - 1) * 0.2 + 0.1"
|
||||
src="../static/star-half.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image v-if="data.totalWinningRate >= i * 0.2" src="../static/star-full.png" mode="widthFix" />
|
||||
<image v-else-if="data.totalWinningRate >= (i - 1) * 0.2 + 0.1" src="../static/star-half.png"
|
||||
mode="widthFix" />
|
||||
<image v-else src="../static/star-empty.png" mode="widthFix" />
|
||||
</block>
|
||||
</view>
|
||||
@@ -120,19 +153,12 @@ onShow(async () => {
|
||||
<view class="founded-room">
|
||||
<image src="../static/founded-room.png" mode="widthFix" />
|
||||
<view>
|
||||
<input
|
||||
placeholder="输入房间号"
|
||||
v-model="roomNumber"
|
||||
placeholder-style="color: #ccc"
|
||||
/>
|
||||
<view @click="enterRoom">进入房间</view>
|
||||
<input placeholder="输入房间号" v-model="roomNumber" placeholder-style="color: #ccc" />
|
||||
<view @click="$clickSound(() => enterRoom(roomNumber))">进入房间</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="create-room">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-07-15/dbcejys872iyun92h6.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image src="https://static.shelingxingqiu.com/attachment/2025-07-15/dbcejys872iyun92h6.png" mode="widthFix" />
|
||||
<image src="../static/room-notfound-title.png" mode="widthFix" />
|
||||
<view>
|
||||
<image :src="user.avatar" mode="widthFix" />
|
||||
@@ -142,22 +168,42 @@ onShow(async () => {
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<SButton width="80%" :rounded="30" :onClick="onCreateRoom">
|
||||
<SButton width="80%" :rounded="30" :onClick="() => $clickSound(onCreateRoom)">
|
||||
创建约战房
|
||||
</SButton>
|
||||
</view>
|
||||
</view>
|
||||
<SModal :show="showModal" :onClose="() => (showModal = false)">
|
||||
<SModal :show="showModal" :onClose="() => (showModal = false)" height="716rpx">
|
||||
<view v-if="warnning" class="warnning">
|
||||
{{ warnning }}
|
||||
</view>
|
||||
<CreateRoom v-if="!warnning" :onConfirm="() => (showModal = false)" />
|
||||
<!-- showModal 关闭时立即销毁组件,重开时重建,确保选项重置为 0 -->
|
||||
<CreateRoom v-if="!warnning && showModal" :onConfirm="() => (showModal = false)" />
|
||||
</SModal>
|
||||
<Signin :show="showSignin" :onClose="onSignin" />
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guide-tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 112rpx;
|
||||
width: 100%;
|
||||
}
|
||||
.guide-tips__main {
|
||||
font-weight: 400;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 217, 71, 0.8);
|
||||
}
|
||||
.guide-tips__sub {
|
||||
font-weight: 400;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.founded-room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -168,10 +214,12 @@ onShow(async () => {
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
.founded-room > image {
|
||||
|
||||
.founded-room>image {
|
||||
width: 16vw;
|
||||
}
|
||||
.founded-room > view {
|
||||
|
||||
.founded-room>view {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -181,14 +229,16 @@ onShow(async () => {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.founded-room > view > input {
|
||||
|
||||
.founded-room>view>input {
|
||||
width: 70%;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
color: #fff;
|
||||
color: #000;
|
||||
}
|
||||
.founded-room > view > view {
|
||||
|
||||
.founded-room>view>view {
|
||||
background-color: #fed847;
|
||||
width: 30%;
|
||||
line-height: 40px;
|
||||
@@ -199,38 +249,45 @@ onShow(async () => {
|
||||
color: #000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.create-room {
|
||||
position: relative;
|
||||
margin: 15px;
|
||||
height: 50vw;
|
||||
}
|
||||
.create-room > image:first-of-type {
|
||||
|
||||
.create-room>image:first-of-type {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
.create-room > image:nth-of-type(2) {
|
||||
|
||||
.create-room>image:nth-of-type(2) {
|
||||
padding: 15px;
|
||||
width: 25vw;
|
||||
position: relative;
|
||||
}
|
||||
.create-room > view:nth-child(3) {
|
||||
|
||||
.create-room>view:nth-child(3) {
|
||||
margin: 12vw auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.create-room > view > image:first-child {
|
||||
|
||||
.create-room>view>image:first-child {
|
||||
width: 19vw;
|
||||
transform: translateY(-60%);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
.create-room > view > image:nth-child(2) {
|
||||
|
||||
.create-room>view>image:nth-child(2) {
|
||||
width: 37vw;
|
||||
position: relative;
|
||||
}
|
||||
.create-room > view > view:nth-child(3) {
|
||||
|
||||
.create-room>view>view:nth-child(3) {
|
||||
position: relative;
|
||||
width: 19vw;
|
||||
height: 19vw;
|
||||
@@ -241,10 +298,12 @@ onShow(async () => {
|
||||
align-items: center;
|
||||
transform: translateY(60%);
|
||||
}
|
||||
.create-room > view > view:nth-child(3) > image {
|
||||
|
||||
.create-room>view>view:nth-child(3)>image {
|
||||
width: 20px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.warnning {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -253,6 +312,7 @@ onShow(async () => {
|
||||
align-items: center;
|
||||
color: #fff9;
|
||||
}
|
||||
|
||||
.my-data {
|
||||
width: calc(100% - 30px);
|
||||
margin: 15px;
|
||||
@@ -262,12 +322,14 @@ onShow(async () => {
|
||||
overflow: hidden;
|
||||
background-color: #54431d33;
|
||||
}
|
||||
.my-data > view {
|
||||
|
||||
.my-data>view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
color: #fff9;
|
||||
}
|
||||
.my-data > view:first-child {
|
||||
|
||||
.my-data>view:first-child {
|
||||
width: calc(100% - 30px);
|
||||
align-items: flex-end;
|
||||
padding-bottom: 15px;
|
||||
@@ -275,16 +337,33 @@ onShow(async () => {
|
||||
margin: 15px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.my-data > view:first-child > text {
|
||||
|
||||
.my-data>view:first-child>.my-record-btn {
|
||||
font-weight: 400;
|
||||
font-size: 24rpx;
|
||||
color: #76D4FF;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.my-data>view:first-child>.member-nickname {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
margin-left: 10px;
|
||||
width: 120px;
|
||||
}
|
||||
.my-data > view:last-child {
|
||||
|
||||
.my-data>view:first-child>.member-nickname__text,
|
||||
.my-data>view:first-child>.member-nickname__shine {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.my-data>view:last-child {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.my-data > view:last-child > view {
|
||||
|
||||
.my-data>view:last-child>view {
|
||||
width: 33%;
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
@@ -292,26 +371,32 @@ onShow(async () => {
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.my-data > view:last-child > view > view {
|
||||
|
||||
.my-data>view:last-child>view>view {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.my-data > view:last-child > view > view > text:first-child {
|
||||
|
||||
.my-data>view:last-child>view>view>text:first-child {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
margin-right: 5px;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
.my-data > view:last-child > view:nth-child(2) {
|
||||
|
||||
.my-data>view:last-child>view:nth-child(2) {
|
||||
border-left: 1px solid #48494e;
|
||||
border-right: 1px solid #48494e;
|
||||
}
|
||||
.my-data > view:last-child > view > view {
|
||||
|
||||
.my-data>view:last-child>view>view {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 20px;
|
||||
}
|
||||
.stars > image {
|
||||
|
||||
.stars>image {
|
||||
width: 4vw;
|
||||
height: 4vw;
|
||||
margin: 0 1px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,7 +28,7 @@ const { user } = storeToRefs(store);
|
||||
</view>
|
||||
|
||||
<!-- 说明文本 -->
|
||||
<view class="content">
|
||||
<view class="body">
|
||||
<view class="intro-text">
|
||||
在射灵世界中,等级是衡量您射箭技能的重要指标,而经验则是您提升等级的关键。具体的要求如下:
|
||||
</view>
|
||||
@@ -68,8 +68,8 @@ const { user } = storeToRefs(store);
|
||||
height: 32rpx;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 20px;
|
||||
padding-top: 20rpx;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.progress-dot {
|
||||
@@ -89,8 +89,8 @@ const { user } = storeToRefs(store);
|
||||
background-color: #fff9;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: calc(100% - 148rpx);
|
||||
.body {
|
||||
height: calc(100% - 146rpx);
|
||||
background-color: #ffffff;
|
||||
padding: 30rpx;
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import { generateCanvasImage } from "@/util";
|
||||
import { getPractiseAPI } from "@/apis";
|
||||
import { wxShare } from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
onLoad(async (options) => {
|
||||
const id = options.id || 461;
|
||||
const data = await getPractiseAPI(id);
|
||||
if (!data.arrows.length) return;
|
||||
generateCanvasImage("shareCanvas", options.type, user.value, data);
|
||||
});
|
||||
|
||||
const saveImage = () => {
|
||||
uni.canvasToTempFilePath({
|
||||
canvasId: "shareCanvas",
|
||||
success: (res) => {
|
||||
const tempFilePath = res.tempFilePath;
|
||||
|
||||
// 保存图片到相册
|
||||
uni.saveImageToPhotosAlbum({
|
||||
filePath: tempFilePath,
|
||||
success: () => {
|
||||
uni.showToast({ title: "保存成功" });
|
||||
},
|
||||
fail: () => {
|
||||
uni.showToast({ title: "保存失败", icon: "error" });
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container>
|
||||
<view class="content">
|
||||
<view :style="{ overflow: 'hidden', borderRadius: '10px' }">
|
||||
<canvas
|
||||
:style="{ width: '300px', height: '534px' }"
|
||||
canvas-id="shareCanvas"
|
||||
></canvas>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.content {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 20px;
|
||||
}
|
||||
.footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-top: 50px;
|
||||
}
|
||||
.footer > button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.footer > button > image {
|
||||
width: 45px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,54 +1,60 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { onShow, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||||
import {onMounted, ref} from "vue";
|
||||
import {onShareAppMessage, onShareTimeline, onShow} from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import AppFooter from "@/components/AppFooter.vue";
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
import UserHeader from "@/components/UserHeader.vue";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import BubbleTip from "@/components/BubbleTip.vue";
|
||||
|
||||
import {
|
||||
checkUserBindAPI,
|
||||
getAppConfig,
|
||||
getRankListAPI,
|
||||
getDeviceBatteryAPI,
|
||||
getHomeData,
|
||||
getMyDevicesAPI,
|
||||
getScoreRankList,
|
||||
silentLoginAPI,
|
||||
} from "@/apis";
|
||||
import { topThreeColors } from "@/constants";
|
||||
import {topThreeColors} from "@/constants";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import {storeToRefs} from "pinia";
|
||||
|
||||
const store = useStore();
|
||||
const { updateConfig, updateUser, updateDevice, updateRank, getLvlName } =
|
||||
store;
|
||||
// 使用storeToRefs,用于UI里显示,保持响应性
|
||||
const { user, device, rankData } = storeToRefs(store);
|
||||
const {
|
||||
updateConfig,
|
||||
updateUser,
|
||||
updateDevice,
|
||||
getLvlName,
|
||||
getLvlNameByScore,
|
||||
updateOnline,
|
||||
} = store;
|
||||
const {user, device, online, game} = storeToRefs(store);
|
||||
|
||||
const showModal = ref(false);
|
||||
const showGuide = ref(false);
|
||||
const scoreRankList = ref([]);
|
||||
|
||||
const toPage = (path) => {
|
||||
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
|
||||
const getScoreRankData = (result) => {
|
||||
if (Array.isArray(result)) return result;
|
||||
if (Array.isArray(result?.list)) return result.list;
|
||||
if (Array.isArray(result?.items)) return result.items;
|
||||
return [];
|
||||
};
|
||||
|
||||
const toPage = async (path) => {
|
||||
if (!user.value.id) {
|
||||
showModal.value = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
"/pages/first-try,/pages/practise,/pages/friend-battle".indexOf(path) !== -1
|
||||
) {
|
||||
if (!device.value.deviceId) {
|
||||
return uni.showToast({
|
||||
title: "请先绑定设备",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
if ("/pages/first-try".indexOf(path) === -1 && !user.value.trio) {
|
||||
return uni.showToast({
|
||||
title: "请先完成新手试炼",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: path,
|
||||
});
|
||||
// if (path === "/pages/first-try") {
|
||||
// if (canEenter(user.value, device.value, online.value, path)) {
|
||||
// await uni.$checkAudio();
|
||||
// }
|
||||
// }
|
||||
uni.navigateTo({url: path});
|
||||
};
|
||||
|
||||
const toRankListPage = () => {
|
||||
@@ -58,24 +64,55 @@ const toRankListPage = () => {
|
||||
};
|
||||
|
||||
onShow(async () => {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||
const token = uni.getStorageSync(`${env}_token`);
|
||||
|
||||
const promises = [getRankListAPI()];
|
||||
if (token) {
|
||||
if (!user.value.id && !token) {
|
||||
// showModal.value = true;
|
||||
// try {
|
||||
// const wxResult = await uni.login({provider: "weixin"});
|
||||
// const bindResult = await checkUserBindAPI(wxResult.code);
|
||||
// if (bindResult.binded) {
|
||||
// const newResult = await uni.login({provider: "weixin"});
|
||||
// const silentResult = await silentLoginAPI(newResult.code);
|
||||
// if (silentResult.user) updateUser(silentResult.user);
|
||||
// const devices = await getMyDevicesAPI();
|
||||
// if (devices.bindings && devices.bindings.length) {
|
||||
// updateDevice(
|
||||
// devices.bindings[0].deviceId,
|
||||
// devices.bindings[0].deviceName
|
||||
// );
|
||||
// const data = await getDeviceBatteryAPI();
|
||||
// updateOnline(data.online);
|
||||
// }
|
||||
// } else {
|
||||
// showModal.value = true;
|
||||
// }
|
||||
// } catch (e) {
|
||||
// console.log("检查绑定状态失败", e);
|
||||
// }
|
||||
}
|
||||
|
||||
const promises = [getScoreRankList(undefined, 1, 10)];
|
||||
if (token || user.value.id) {
|
||||
promises.push(getHomeData());
|
||||
}
|
||||
|
||||
const [rankList, homeData] = await Promise.all(promises);
|
||||
|
||||
console.log("排行数据", rankList);
|
||||
updateRank(rankList);
|
||||
console.log("积分榜数据", rankList);
|
||||
scoreRankList.value = getScoreRankData(rankList).slice(0, 10);
|
||||
|
||||
if (homeData) {
|
||||
console.log("首页数据:", homeData);
|
||||
if (homeData.user) {
|
||||
updateUser(homeData.user);
|
||||
if ("823,209,293,257".indexOf(homeData.user.id) !== -1) {
|
||||
const show = uni.getStorageSync("show-the-user");
|
||||
if (!show) {
|
||||
uni.setStorageSync("show-the-user", true);
|
||||
}
|
||||
}
|
||||
if (homeData.user.trio <= 0) {
|
||||
showGuide.value = true;
|
||||
setTimeout(() => {
|
||||
@@ -85,9 +122,11 @@ onShow(async () => {
|
||||
const devices = await getMyDevicesAPI();
|
||||
if (devices.bindings && devices.bindings.length) {
|
||||
updateDevice(
|
||||
devices.bindings[0].deviceId,
|
||||
devices.bindings[0].deviceName
|
||||
devices.bindings[0].deviceId,
|
||||
devices.bindings[0].deviceName
|
||||
);
|
||||
const data = await getDeviceBatteryAPI();
|
||||
updateOnline(data.online);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,19 +138,12 @@ onMounted(async () => {
|
||||
console.log("全局配置:", config);
|
||||
});
|
||||
|
||||
const comingSoon = () => {
|
||||
uni.showToast({
|
||||
title: "敬请期待",
|
||||
icon: "none",
|
||||
});
|
||||
};
|
||||
|
||||
onShareAppMessage(() => {
|
||||
return {
|
||||
title: "智能真弓:实时捕捉+毫秒级同步,弓箭选手全球竞技!", // 分享卡片的标题
|
||||
path: "/pages/index", // 用户点击分享卡片后跳转的页面路径
|
||||
imageUrl:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-12/dcqoz26q0268wxmzjg.png", // 分享卡片的配图,可以是本地或网络图片
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-12/dcqoz26q0268wxmzjg.png", // 分享卡片的配图,可以是本地或网络图片
|
||||
};
|
||||
});
|
||||
onShareTimeline(() => {
|
||||
@@ -119,7 +151,7 @@ onShareTimeline(() => {
|
||||
title: "智能真弓:实时捕捉+毫秒级同步,弓箭选手全球竞技!", // 分享到朋友圈的标题
|
||||
query: "from=timeline", // 用户通过朋友圈点击后,在页面 onShow 的 options 中可以获取到的参数
|
||||
imageUrl:
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-12/dcqoz26q0268wxmzjg.png", // 分享到朋友圈的配图
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-12/dcqoz26q0268wxmzjg.png", // 分享到朋友圈的配图
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -127,98 +159,106 @@ onShareTimeline(() => {
|
||||
<template>
|
||||
<Container :isHome="true" :showBackToGame="true">
|
||||
<view class="container">
|
||||
<UserHeader showRank :onSignin="() => (showModal = true)" />
|
||||
<view class="top-theme">
|
||||
<!-- <image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-12-31/dfc9dxrq4xn7e6y2pp.png"
|
||||
mode="widthFix"
|
||||
/> -->
|
||||
</view>
|
||||
<UserHeader showRank :onSignin="() => (showModal = true)"/>
|
||||
<view :style="{ padding: '12px 10px' }">
|
||||
<view class="feature-grid">
|
||||
<view class="bow-card">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-08-07/dbvt1o6dvhr2rop3kn.webp"
|
||||
mode="widthFix"
|
||||
@click="() => toPage('/pages/my-device')"
|
||||
v-if="online"
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-08-07/dbvt1o6dvhr2rop3kn.webp"
|
||||
mode="widthFix"
|
||||
@click="() => toPage('/pages/my-device')"
|
||||
/>
|
||||
<text v-if="!user.id">我的弓箭</text>
|
||||
<text v-if="user.id && !device.deviceId">连接智能弓箭</text>
|
||||
<text
|
||||
v-if="user.id && device.deviceId"
|
||||
class="truncate"
|
||||
:style="{ width: '90%', textAlign: 'center' }"
|
||||
>{{ device.deviceName }}</text
|
||||
>
|
||||
<image
|
||||
src="../static/first-try.png"
|
||||
mode="widthFix"
|
||||
@click="() => toPage('/pages/first-try')"
|
||||
v-else
|
||||
src="https://static.shelingxingqiu.com/attachment/2026-01-04/dffohwtk1gwh0xfa6h.png"
|
||||
mode="widthFix"
|
||||
@click="$clickSound(() => toPage('/pages/my-device'))"
|
||||
/>
|
||||
<BubbleTip
|
||||
v-if="showGuide"
|
||||
:location="{ top: '60%', left: '40%', fontSize: '14px' }"
|
||||
>
|
||||
<block v-if="user.id">
|
||||
<text v-if="!device.deviceId">绑定我的智能弓</text>
|
||||
<text v-else-if="!online">设备离线</text>
|
||||
<text v-else-if="online">设备在线</text>
|
||||
</block>
|
||||
<image
|
||||
src="../static/first-try.png"
|
||||
mode="widthFix"
|
||||
@click="() => toPage('/pages/first-try')"
|
||||
/>
|
||||
<BubbleTip v-if="showGuide" :location="{ top: '60%', left: '47%' }">
|
||||
<text>新人必刷!</text>
|
||||
<text>快来报到吧~</text>
|
||||
</BubbleTip>
|
||||
</view>
|
||||
<view class="play-card">
|
||||
<view @click="() => toPage('/pages/practise')">
|
||||
<image src="../static/my-practise.png" mode="widthFix" />
|
||||
<view @click="$clickSound(() => toPage('/pages/practise'))">
|
||||
<image src="../static/my-practise.png" mode="widthFix"/>
|
||||
</view>
|
||||
<view @click="() => toPage('/pages/friend-battle')">
|
||||
<image src="../static/friend-battle.png" mode="widthFix" />
|
||||
<view @click="$clickSound(() => toPage('/pages/friend-battle'))">
|
||||
<image src="../static/friend-battle.png" mode="widthFix"/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="ranking-section">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-09-25/dd1p9ci9v7frcrsxhj.png"
|
||||
mode="widthFix"
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-09-25/dd1p9ci9v7frcrsxhj.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<button
|
||||
class="into-btn"
|
||||
@click="() => toPage('/pages/ranking')"
|
||||
hover-class="none"
|
||||
class="into-btn"
|
||||
@click="$clickSound(() => toPage('/pages/ranking'))"
|
||||
hover-class="none"
|
||||
></button>
|
||||
<view class="ranking-players" @click="toRankListPage">
|
||||
<img src="../static/juezhanbang.png" mode="widthFix" />
|
||||
<img src="../static/juezhanbang.png" mode="widthFix"/>
|
||||
<view class="divide-line"></view>
|
||||
<view class="player-avatars">
|
||||
<view
|
||||
v-for="i in 6"
|
||||
:key="i"
|
||||
class="player-avatar"
|
||||
:style="{
|
||||
v-for="i in 6"
|
||||
:key="i"
|
||||
class="player-avatar"
|
||||
:style="{
|
||||
zIndex: 8 - i,
|
||||
borderColor: rankData.rank[i - 1]
|
||||
borderColor: scoreRankList[i - 1]
|
||||
? topThreeColors[i - 1] || '#000'
|
||||
: '#000',
|
||||
}"
|
||||
>
|
||||
<image v-if="i === 1" src="../static/champ1.png" />
|
||||
<image v-if="i === 2" src="../static/champ2.png" />
|
||||
<image v-if="i === 3" src="../static/champ3.png" />
|
||||
<image v-if="i === 1" src="../static/champ1.png"/>
|
||||
<image v-if="i === 2" src="../static/champ2.png"/>
|
||||
<image v-if="i === 3" src="../static/champ3.png"/>
|
||||
<view v-if="i > 3">{{ i }}</view>
|
||||
<image
|
||||
:src="
|
||||
rankData.rank[i - 1]
|
||||
? rankData.rank[i - 1].avatar
|
||||
:src="
|
||||
scoreRankList[i - 1]
|
||||
? (scoreRankList[i - 1].avatar || '../static/user-icon.png')
|
||||
: '../static/user-icon-dark.png'
|
||||
"
|
||||
mode="aspectFill"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</view>
|
||||
<view class="more-players">
|
||||
<text>{{ rankData.rank.length }}</text>
|
||||
<text>{{ scoreRankList.length }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="my-data">
|
||||
<view @click="() => toPage('/pages/my-growth')">
|
||||
<image src="../static/my-growth.png" mode="widthFix" />
|
||||
<image src="../static/my-growth.png" mode="widthFix"/>
|
||||
</view>
|
||||
<view @click="() => toPage('/pages/ranking')">
|
||||
<view>
|
||||
<text>段位</text>
|
||||
<text>{{
|
||||
user.scores ? getLvlName(user.scores) : "暂无"
|
||||
}}</text>
|
||||
user.lvlName || "暂无"
|
||||
}}
|
||||
</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>赛季平均环数</text>
|
||||
@@ -227,79 +267,26 @@ onShareTimeline(() => {
|
||||
<view>
|
||||
<text>赛季胜率</text>
|
||||
<text>{{
|
||||
user.avg_win
|
||||
? Number((user.avg_win * 100).toFixed(2)) + "%"
|
||||
: "暂无"
|
||||
}}</text>
|
||||
user.avg_win
|
||||
? Number((user.avg_win * 100).toFixed(2)) + "%"
|
||||
: "暂无"
|
||||
}}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- <view class="region-stats">
|
||||
<view
|
||||
v-for="(region, index) in [
|
||||
{ name: '广东', score: 4291 },
|
||||
{ name: '湖南', score: 3095 },
|
||||
{ name: '内蒙', score: 2342 },
|
||||
{ name: '海南', score: 1812 },
|
||||
{ name: '四川', score: 1293 },
|
||||
]"
|
||||
:key="index"
|
||||
class="region-item"
|
||||
@click="comingSoon"
|
||||
>
|
||||
<image src="../static/region-bg.png" mode="widthFix" />
|
||||
<image
|
||||
v-if="index === 0"
|
||||
src="../static/region-1.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="index === 1"
|
||||
src="../static/region-2.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="index === 2"
|
||||
src="../static/region-3.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="index === 3"
|
||||
src="../static/region-4.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="index === 4"
|
||||
src="../static/region-5.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text>{{ region.name }}</text>
|
||||
<view>
|
||||
<text :style="{ color: '#fff', marginRight: '2px' }">{{
|
||||
region.score
|
||||
}}</text>
|
||||
<text>分</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="region-more" @click="comingSoon">
|
||||
<image src="../static/region-more.png" mode="widthFix" />
|
||||
<text>...</text>
|
||||
<text>更多</text>
|
||||
</view>
|
||||
</view> -->
|
||||
</view>
|
||||
</view>
|
||||
<SModal :show="showModal" :onClose="() => (showModal = false)">
|
||||
<Signin :onClose="() => (showModal = false)" />
|
||||
</SModal>
|
||||
<Signin :show="showModal" :onClose="() => (showModal = false)"/>
|
||||
</view>
|
||||
<AppFooter />
|
||||
<AppFooter/>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: calc(100% - 120px);
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
@@ -316,6 +303,8 @@ onShareTimeline(() => {
|
||||
|
||||
.bow-card {
|
||||
width: 50%;
|
||||
border-radius: 25rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feature-grid > view > image {
|
||||
@@ -324,7 +313,7 @@ onShareTimeline(() => {
|
||||
|
||||
.bow-card > text {
|
||||
position: absolute;
|
||||
top: 65%;
|
||||
top: 66%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
white-space: nowrap;
|
||||
@@ -411,6 +400,7 @@ onShareTimeline(() => {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.player-avatar > view:first-child {
|
||||
border-radius: 50%;
|
||||
background: #777777;
|
||||
@@ -421,6 +411,7 @@ onShareTimeline(() => {
|
||||
height: 18px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.player-avatar > image:last-child {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -440,71 +431,21 @@ onShareTimeline(() => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.region-stats {
|
||||
display: flex;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
margin-top: 20px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.region-item,
|
||||
.region-more {
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
width: 13vw;
|
||||
height: 13vw;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #c5c5c5;
|
||||
font-size: 12px;
|
||||
}
|
||||
.region-item > text {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.region-more {
|
||||
width: 8vw;
|
||||
height: 13vw;
|
||||
}
|
||||
.region-item > image:first-child,
|
||||
.region-more > image:first-child {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
z-index: -1;
|
||||
}
|
||||
.region-item > image:nth-of-type(2) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 18px;
|
||||
}
|
||||
.region-item > view:last-child {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
.region-more > text:first-of-type {
|
||||
font-size: 30px;
|
||||
line-height: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.my-data {
|
||||
display: flex;
|
||||
margin-top: 20px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.my-data > view:first-child {
|
||||
width: 28%;
|
||||
}
|
||||
|
||||
.my-data > view:first-child > image {
|
||||
width: 100%;
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
|
||||
.my-data > view:nth-child(2) {
|
||||
width: 68%;
|
||||
font-size: 12px;
|
||||
@@ -512,9 +453,11 @@ onShareTimeline(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.my-data > view:nth-child(2) > view:nth-child(2) {
|
||||
width: 38%;
|
||||
}
|
||||
|
||||
.my-data > view:nth-child(2) > view {
|
||||
width: 28%;
|
||||
border-radius: 10px;
|
||||
@@ -524,9 +467,25 @@ onShareTimeline(() => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.my-data > view:nth-child(2) > view > text:last-child {
|
||||
color: #fff;
|
||||
line-height: 25px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.top-theme {
|
||||
position: absolute;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.top-theme > image {
|
||||
width: 300rpx;
|
||||
transform: translate(-4%, -14%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,91 +4,72 @@ import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import BattleHeader from "@/components/BattleHeader.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
// import TeamResult from "@/components/TeamResult.vue";
|
||||
// import MeleeResult from "@/components/MeleeResult.vue";
|
||||
import PlayerScore2 from "@/components/PlayerScore2.vue";
|
||||
import { getGameAPI } from "@/apis";
|
||||
import { getBattleAPI } from "@/apis";
|
||||
|
||||
const blueTeam = ref([]);
|
||||
const redTeam = ref([]);
|
||||
const roundsData = ref([]);
|
||||
const goldenRoundsData = ref([]);
|
||||
const battleId = ref("");
|
||||
const data = ref({
|
||||
players: [],
|
||||
teams: [],
|
||||
rounds: [],
|
||||
});
|
||||
// const show = ref(false);
|
||||
const players = ref([]);
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (options.id) {
|
||||
battleId.value = options.id || "BATTLE-1755484626207409508-955";
|
||||
const result = await getGameAPI(battleId.value);
|
||||
data.value = result;
|
||||
if (result.mode === 1) {
|
||||
blueTeam.value = Object.values(result.bluePlayers || {});
|
||||
redTeam.value = Object.values(result.redPlayers || {});
|
||||
Object.values(result.roundsData).forEach((item) => {
|
||||
let bluePoint = 1;
|
||||
let redPoint = 1;
|
||||
let blueTotalRings = 0;
|
||||
let redTotalRings = 0;
|
||||
let blueArrows = [];
|
||||
let redArrows = [];
|
||||
blueTeam.value.forEach((p) => {
|
||||
if (!item[p.playerId]) return;
|
||||
blueTotalRings += item[p.playerId].reduce((a, b) => a + b.ring, 0);
|
||||
blueArrows = [...blueArrows, ...item[p.playerId]];
|
||||
});
|
||||
redTeam.value.forEach((p) => {
|
||||
if (!item[p.playerId]) return;
|
||||
redTotalRings += item[p.playerId].reduce((a, b) => a + b.ring, 0);
|
||||
redArrows = [...redArrows, ...item[p.playerId]];
|
||||
});
|
||||
if (blueTotalRings > redTotalRings) {
|
||||
bluePoint = 2;
|
||||
redPoint = 0;
|
||||
} else if (blueTotalRings < redTotalRings) {
|
||||
bluePoint = 0;
|
||||
redPoint = 2;
|
||||
if (!options.battleId) return;
|
||||
battleId.value = options.battleId || "60510101693403136";
|
||||
const result = await getBattleAPI(battleId.value);
|
||||
data.value = result;
|
||||
if (result.mode > 3) {
|
||||
const plist = result.teams[0] ? result.teams[0].players : [];
|
||||
// 以 id 为 key 建立 teams 玩家快速查找表
|
||||
const teamPlayerMap = {};
|
||||
plist.forEach((p) => { teamPlayerMap[p.id] = p; });
|
||||
|
||||
// 处理有成绩的玩家(resultList 顺序即排名顺序)
|
||||
const rankedPlayers = (result.resultList || []).map((item, index) => {
|
||||
const playerId = item.userId || item.id;
|
||||
const p = teamPlayerMap[playerId] || item;
|
||||
const arrows = new Array(12);
|
||||
result.rounds.forEach((r, rIndex) => {
|
||||
if (r.shoots[playerId]) {
|
||||
r.shoots[playerId].forEach((s, sIndex) => {
|
||||
arrows[sIndex + rIndex * 6] = s;
|
||||
});
|
||||
}
|
||||
roundsData.value.push({
|
||||
blue: {
|
||||
avatars: blueTeam.value.map((p) => p.avatar),
|
||||
arrows: blueArrows,
|
||||
totalRing: blueTotalRings,
|
||||
totalScore: bluePoint,
|
||||
},
|
||||
red: {
|
||||
avatars: redTeam.value.map((p) => p.avatar),
|
||||
arrows: redArrows,
|
||||
totalRing: redTotalRings,
|
||||
totalScore: redPoint,
|
||||
},
|
||||
});
|
||||
});
|
||||
result.goldenRounds.forEach((round) => {
|
||||
goldenRoundsData.value.push({
|
||||
blue: {
|
||||
avatars: blueTeam.value.map((p) => p.avatar),
|
||||
arrows: round.arrowHistory.filter((a) => a.team === 1),
|
||||
},
|
||||
red: {
|
||||
avatars: redTeam.value.map((p) => p.avatar),
|
||||
arrows: round.arrowHistory.filter((a) => a.team === 0),
|
||||
},
|
||||
winner: round.winner,
|
||||
});
|
||||
});
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
id: playerId,
|
||||
rank: index + 1,
|
||||
name: (p && p.name) || item.name,
|
||||
avatar: (p && p.avatar) || item.avatar || "",
|
||||
arrows,
|
||||
};
|
||||
});
|
||||
|
||||
// 追加未出现在 resultList 中的玩家(未射箭),rank=0 隐藏角标
|
||||
const rankedIds = new Set(rankedPlayers.map((p) => p.id));
|
||||
const unrankedPlayers = plist
|
||||
.filter((p) => !rankedIds.has(p.id))
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
avatar: p.avatar || "",
|
||||
arrows: [],
|
||||
totalScore: 0,
|
||||
rank: 0,
|
||||
}));
|
||||
|
||||
players.value = [...rankedPlayers, ...unrankedPlayers];
|
||||
}
|
||||
});
|
||||
|
||||
const checkBowData = () => {
|
||||
if (data.value.mode === 1) {
|
||||
const checkBowData = (selected) => {
|
||||
if (data.value.mode <= 3) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/team-bow-data?battleId=${battleId.value}`,
|
||||
url: `/pages/team-battle/team-bow-data?battleId=${battleId.value}&selected=${selected}`,
|
||||
});
|
||||
} else if (data.value.mode === 2) {
|
||||
} else {
|
||||
uni.navigateTo({
|
||||
url: `/pages/melee-bow-data?battleId=${battleId.value}`,
|
||||
});
|
||||
@@ -100,13 +81,14 @@ const checkBowData = () => {
|
||||
<Container title="详情">
|
||||
<view class="container">
|
||||
<BattleHeader
|
||||
:winner="data.winner"
|
||||
:blueTeam="blueTeam"
|
||||
:redTeam="redTeam"
|
||||
:players="data.players"
|
||||
v-if="data.mode <= 3"
|
||||
:winner="data.winTeam"
|
||||
:blueTeam="data.teams[1] ? data.teams[1].players : []"
|
||||
:redTeam="data.teams[2] ? data.teams[2].players : []"
|
||||
:players="players"
|
||||
/>
|
||||
<view
|
||||
v-if="data.players && data.players.length"
|
||||
v-if="data.mode > 3"
|
||||
class="score-header"
|
||||
:style="{ border: 'none', padding: '5px 15px' }"
|
||||
>
|
||||
@@ -117,156 +99,64 @@ const checkBowData = () => {
|
||||
</view>
|
||||
</view>
|
||||
<PlayerScore2
|
||||
v-if="data.players && data.players.length"
|
||||
v-for="(player, index) in data.players"
|
||||
v-if="data.mode > 3"
|
||||
v-for="(player, index) in players"
|
||||
:key="index"
|
||||
:name="player.name"
|
||||
:avatar="player.avatar"
|
||||
:scores="player.arrowHistory"
|
||||
:arrows="player.arrows"
|
||||
:totalScore="player.totalScore"
|
||||
:totalRing="player.totalRings"
|
||||
:rank="index + 1"
|
||||
:rank="player.rank"
|
||||
/>
|
||||
<block v-for="(round, index) in goldenRoundsData" :key="index">
|
||||
<view
|
||||
v-if="data.mode <= 3"
|
||||
v-for="(round, index) in data.rounds"
|
||||
:key="index"
|
||||
:style="{ marginBottom: '5px' }"
|
||||
>
|
||||
<view class="score-header">
|
||||
<text>决金箭轮(环数)</text>
|
||||
<view @click="checkBowData">
|
||||
<text>{{ round.ifGold ? "决金箭" : `第${index + 1}轮` }}</text>
|
||||
<view @click="() => checkBowData(index)">
|
||||
<text>查看靶纸</text>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="score-row">
|
||||
<view
|
||||
class="score-row"
|
||||
v-for="team in Object.keys(round.shoots)"
|
||||
:key="team"
|
||||
>
|
||||
<view>
|
||||
<view>
|
||||
<image
|
||||
v-for="(src, index) in round.blue.avatars"
|
||||
v-for="(p, index) in data.teams[team].players"
|
||||
:style="{
|
||||
borderColor: '#64BAFF',
|
||||
transform: `translateX(-${index * 15}px)`,
|
||||
}"
|
||||
:src="src"
|
||||
:src="p.avatar || '../static/user-icon.png'"
|
||||
:key="index"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<text v-for="(arrow, index) in round.blue.arrows" :key="index">
|
||||
{{ arrow.ring }}环
|
||||
<text
|
||||
v-for="(arrow, index2) in round.shoots[team]"
|
||||
:key="index2"
|
||||
:style="{ color: arrow.ringX ? '#fed847' : '#ccc' }"
|
||||
>
|
||||
{{ arrow.ringX ? "X" : `${arrow.ring}环` }}
|
||||
</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="round.winner === 1"
|
||||
src="../static/winner-badge.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<view class="score-row" :style="{ marginBottom: '5px' }">
|
||||
<view>
|
||||
<view>
|
||||
<image
|
||||
v-for="(src, index) in round.red.avatars"
|
||||
:style="{
|
||||
borderColor: '#FF6767',
|
||||
transform: `translateX(-${index * 15}px)`,
|
||||
}"
|
||||
:src="src || '../static/user-icon.png'"
|
||||
:key="index"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<text v-for="(arrow, index) in round.red.arrows" :key="index">
|
||||
{{ arrow.ring }}环
|
||||
<text :style="{ color: team == 1 ? '#64BAFF' : '#FF6767' }">
|
||||
{{ round.shoots[team].reduce((acc, cur) => acc + cur.ring, 0) }}环
|
||||
</text>
|
||||
<text>得分 {{ round.scores[team].score }}</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="round.winner === 0"
|
||||
src="../static/winner-badge.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
</block>
|
||||
<view
|
||||
v-for="(round, index) in roundsData"
|
||||
:key="index"
|
||||
:style="{ marginBottom: '5px' }"
|
||||
>
|
||||
<block
|
||||
v-if="
|
||||
index < Object.keys(roundsData).length - goldenRoundsData.length
|
||||
"
|
||||
>
|
||||
<view class="score-header">
|
||||
<text>第{{ index + 1 }}轮</text>
|
||||
<view @click="checkBowData">
|
||||
<text>查看靶纸</text>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="score-row">
|
||||
<view>
|
||||
<view>
|
||||
<image
|
||||
v-for="(src, index) in round.blue.avatars"
|
||||
:style="{
|
||||
borderColor: '#64BAFF',
|
||||
transform: `translateX(-${index * 15}px)`,
|
||||
}"
|
||||
:src="src || '../static/user-icon.png'"
|
||||
:key="index"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<text v-for="(arrow, index2) in round.blue.arrows" :key="index2">
|
||||
{{ arrow.ring }}环
|
||||
</text>
|
||||
</view>
|
||||
<view>
|
||||
<text :style="{ color: '#64BAFF' }">
|
||||
{{ round.blue.totalRing }}环
|
||||
</text>
|
||||
<text>得分 {{ round.blue.totalScore }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="score-row">
|
||||
<view>
|
||||
<view>
|
||||
<image
|
||||
v-for="(src, index) in round.red.avatars"
|
||||
:style="{
|
||||
borderColor: '#FF6767',
|
||||
transform: `translateX(-${index * 15}px)`,
|
||||
}"
|
||||
:src="src"
|
||||
:key="index"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<text v-for="(arrow, index2) in round.red.arrows" :key="index2">
|
||||
{{ arrow.ring }}环
|
||||
</text>
|
||||
</view>
|
||||
<view>
|
||||
<text :style="{ color: '#FF6767' }">
|
||||
{{ round.red.totalRing }}环
|
||||
</text>
|
||||
<text>得分 {{ round.red.totalScore }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
<view :style="{ height: '20px' }"></view>
|
||||
</view>
|
||||
<!-- <TeamResult
|
||||
v-if="data.mode === 1"
|
||||
:show="show"
|
||||
:onClose="() => (show = false)"
|
||||
:data="data"
|
||||
/>
|
||||
<MeleeResult
|
||||
v-if="data.mode === 2"
|
||||
:show="show"
|
||||
:onClose="() => (show = false)"
|
||||
:data="data"
|
||||
/> -->
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -3,46 +3,111 @@ import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Matching from "@/components/Matching.vue";
|
||||
import RoundEndTip from "@/components/RoundEndTip.vue";
|
||||
import TestDistance from "@/components/TestDistance.vue";
|
||||
import { matchGameAPI } from "@/apis";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import { matchGameAPI, getBattleAPI } from "@/apis";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
|
||||
const gameType = ref(0);
|
||||
const teamSize = ref(0);
|
||||
const onComplete = ref(null);
|
||||
|
||||
/** 匹配超时计时器,用于检测 WS 消息丢失或真正超时 */
|
||||
const matchTimeoutTimer = ref(null);
|
||||
|
||||
/** 匹配超时阈值(ms),后端设置 15s 匹配,前端预留 5s 冗余 */
|
||||
const MATCH_TIMEOUT_MS = 20 * 1000;
|
||||
|
||||
/** 清除超时计时器 */
|
||||
const clearMatchTimeout = () => {
|
||||
if (matchTimeoutTimer.value) {
|
||||
clearTimeout(matchTimeoutTimer.value);
|
||||
matchTimeoutTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 超时处理:查询后端是否已分配对局
|
||||
* - 有对局 → WS 消息丢失场景,自动跳入
|
||||
* - 无对局 → 真正超时,提示用户并返回大厅
|
||||
*/
|
||||
const handleMatchTimeout = async () => {
|
||||
try {
|
||||
const battle = await getBattleAPI();
|
||||
if (battle && battle.matchId) {
|
||||
uni.showToast({ title: "匹配成功,正在进入...", icon: "none" });
|
||||
if (battle.mode <= 3) {
|
||||
uni.redirectTo({ url: `/pages/team-battle/index?battleId=${battle.matchId}` });
|
||||
} else {
|
||||
uni.redirectTo({ url: `/pages/melee-battle?battleId=${battle.matchId}` });
|
||||
}
|
||||
} else {
|
||||
uni.showToast({ title: "匹配超时,请重试", icon: "none" });
|
||||
try {
|
||||
if (gameType.value && teamSize.value) {
|
||||
await matchGameAPI(false, gameType.value, teamSize.value);
|
||||
}
|
||||
} catch (_) { /* 取消失败忽略 */ }
|
||||
uni.navigateBack();
|
||||
}
|
||||
} catch (e) {
|
||||
uni.showToast({ title: "匹配超时,请重试", icon: "none" });
|
||||
uni.navigateBack();
|
||||
}
|
||||
};
|
||||
|
||||
async function stopMatch() {
|
||||
uni.$showHint(3);
|
||||
}
|
||||
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.WaitForAllReady) {
|
||||
if (!onComplete.value) {
|
||||
onComplete.value = () => {
|
||||
if (msg.groupUserStatus) {
|
||||
uni.setStorageSync("red-team", msg.groupUserStatus.redTeam);
|
||||
uni.setStorageSync("blue-team", msg.groupUserStatus.blueTeam);
|
||||
uni.setStorageSync("melee-players", [
|
||||
...msg.groupUserStatus.redTeam,
|
||||
...msg.groupUserStatus.blueTeam,
|
||||
]);
|
||||
}
|
||||
uni.removeStorageSync("current-battle");
|
||||
if (gameType.value == 1) {
|
||||
uni.redirectTo({
|
||||
url: `/pages/team-battle?battleId=${msg.id}&gameMode=2`,
|
||||
});
|
||||
} else if (gameType.value == 2) {
|
||||
uni.redirectTo({
|
||||
url: `/pages/melee-match?battleId=${msg.id}&gameMode=2`,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 取消匹配,带容错处理:
|
||||
* - 取消成功 → 返回大厅
|
||||
* - 取消失败(后端已分配对局但 WS 未到达)→ 查询并跳入当前对局
|
||||
*/
|
||||
async function cancelMatch() {
|
||||
clearMatchTimeout();
|
||||
try {
|
||||
if (gameType.value && teamSize.value) {
|
||||
await matchGameAPI(false, gameType.value, teamSize.value);
|
||||
}
|
||||
});
|
||||
uni.navigateBack();
|
||||
} catch (e) {
|
||||
// 取消匹配接口失败,尝试查询是否已被分配对局
|
||||
try {
|
||||
const battle = await getBattleAPI();
|
||||
if (battle && battle.matchId) {
|
||||
if (battle.mode <= 3) {
|
||||
uni.redirectTo({ url: `/pages/team-battle/index?battleId=${battle.matchId}` });
|
||||
} else {
|
||||
uni.redirectTo({ url: `/pages/melee-battle?battleId=${battle.matchId}` });
|
||||
}
|
||||
} else {
|
||||
uni.navigateBack();
|
||||
}
|
||||
} catch (_) {
|
||||
uni.navigateBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.MatchSuccess) {
|
||||
onComplete.value = () => {}
|
||||
}
|
||||
if (msg.type === MESSAGETYPESV2.AboutToStart) {
|
||||
// 收到开始消息,清除超时计时器
|
||||
clearMatchTimeout();
|
||||
// 使用后端下发的 mode 字段判断跳转目标,与好友约战(battle-room.vue)保持一致
|
||||
// mode <= 3 为团队对抗,mode > 3 为大乱斗,覆盖全部 gameType(1~5),不再遗漏
|
||||
if (msg.mode <= 3) {
|
||||
uni.redirectTo({
|
||||
url: `/pages/team-battle/index?battleId=${msg.id}`,
|
||||
});
|
||||
} else {
|
||||
uni.redirectTo({
|
||||
url: `/pages/melee-battle?battleId=${msg.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
@@ -57,28 +122,29 @@ onMounted(() => {
|
||||
keepScreenOn: true,
|
||||
});
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("cancelMatching", cancelMatch);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearMatchTimeout();
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: false,
|
||||
});
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
if (gameType.value && teamSize.value) {
|
||||
matchGameAPI(false, gameType.value, teamSize.value);
|
||||
}
|
||||
uni.$off("cancelMatching", cancelMatch);
|
||||
});
|
||||
|
||||
onShow(async () => {
|
||||
if (gameType.value && teamSize.value) {
|
||||
matchGameAPI(true, gameType.value, teamSize.value);
|
||||
// 启动超时计时器,防止 WS 消息丢失或长时间无对手导致用户卡死
|
||||
clearMatchTimeout();
|
||||
matchTimeoutTimer.value = setTimeout(handleMatchTimeout, MATCH_TIMEOUT_MS);
|
||||
}
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
if (gameType.value && teamSize.value) {
|
||||
matchGameAPI(false, gameType.value, teamSize.value);
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
316
src/pages/melee-battle.vue
Normal file
316
src/pages/melee-battle.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick, computed } from "vue";
|
||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
import ShootProgress from "@/components/ShootProgress.vue";
|
||||
import BattleHeader from "@/components/BattleHeader.vue";
|
||||
import PlayerScore from "@/components/PlayerScore.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import TestDistance from "@/components/TestDistance.vue";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { getBattleAPI, laserCloseAPI } from "@/apis";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, online } = storeToRefs(store);
|
||||
const title = ref("");
|
||||
const start = ref(null);
|
||||
const battleId = ref("");
|
||||
/** 对战模式:1=好友约战 2=排位赛,用于结算页跳转判断 */
|
||||
const way = ref(0);
|
||||
const currentRound = ref(1);
|
||||
const tips = ref("即将开始...");
|
||||
const players = ref([]);
|
||||
const playersSorted = ref([]);
|
||||
const playersScores = ref([]);
|
||||
const halfTimeTip = ref(false);
|
||||
const halfRest = ref(false);
|
||||
/** 控制设备离线提示弹窗的显示状态 */
|
||||
const showOfflineModal = ref(false);
|
||||
/** 记录每位玩家当前半场连续 X 环数,key 为 playerId,用于触发 tententen 音效 */
|
||||
const xRingStreaks = ref({});
|
||||
|
||||
const currentPlayer = computed(() =>
|
||||
players.value.find((player) => String(player?.id) === String(user.value.id))
|
||||
);
|
||||
const isCurrentUserSvip = computed(() => currentPlayer.value?.sVip === true);
|
||||
|
||||
/**
|
||||
* 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户
|
||||
*/
|
||||
watch(online, (newVal, oldVal) => {
|
||||
if (!newVal && oldVal && start.value === true) {
|
||||
showOfflineModal.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
function recoverData(battleInfo, { force = false } = {}) {
|
||||
if (!battleInfo) return;
|
||||
try {
|
||||
if (battleInfo.way === 1) title.value = "好友约战 - 大乱斗";
|
||||
if (battleInfo.way === 2) title.value = "排位赛 - 大乱斗";
|
||||
// 保存 way 供结算跳转时使用
|
||||
way.value = battleInfo.way ?? 0;
|
||||
|
||||
// 优先使用接口数据,否则使用缓存
|
||||
if (battleInfo.teams?.[0]?.players) {
|
||||
players.value = [...battleInfo.teams[0].players];
|
||||
} else {
|
||||
// 大乱斗可能存的是 players 列表
|
||||
// 这里的缓存逻辑根据 AboutToStart 消息结构可能不同,假设也是 teams[0]
|
||||
// 如果是从 match-page 过来的,match-page 只存了 teams[1] 和 [2] 给对抗模式
|
||||
// 大乱斗的匹配逻辑可能不同,暂时保持原样,只做安全保护
|
||||
players.value = [];
|
||||
}
|
||||
|
||||
start.value = battleInfo.status !== 0;
|
||||
|
||||
if (battleInfo.status === 0) {
|
||||
const readyRemain = (Date.now() - (battleInfo.createTime || Date.now())) / 1000;
|
||||
if (readyRemain > 0 && readyRemain < 15) {
|
||||
setTimeout(() => uni.$emit("update-timer", 15 - readyRemain - 0.2), 200);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
tips.value =
|
||||
(battleInfo.rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭";
|
||||
playersScores.value = battleInfo.rounds.map((r) => ({ ...r.shoots }));
|
||||
const totals = {};
|
||||
players.value.forEach((p) => {
|
||||
const total = playersScores.value.reduce((acc, round) => {
|
||||
const arr = round[p.id] || [];
|
||||
return acc + arr.length;
|
||||
}, 0);
|
||||
totals[p.id] = total;
|
||||
});
|
||||
playersSorted.value = players.value.slice().sort((a, b) => {
|
||||
return totals[b.id] - totals[a.id];
|
||||
});
|
||||
if (battleInfo.status === 3) {
|
||||
halfTimeTip.value = true;
|
||||
halfRest.value = true;
|
||||
tips.value = "准备下半场";
|
||||
// 剩余休息时间
|
||||
// const remain = (Date.now() - battleInfo.timeoutTime) / 1000;
|
||||
setTimeout(() => {
|
||||
uni.$emit("update-remain", 0);
|
||||
}, 200);
|
||||
return;
|
||||
}
|
||||
if (force) {
|
||||
const remain = (Date.now() - (battleInfo.current?.startTime || Date.now())) / 1000;
|
||||
console.log(`当前轮已进行${remain}秒`);
|
||||
if (remain > 0 && remain < 90) {
|
||||
setTimeout(() => {
|
||||
uni.$emit("update-remain", 90 - remain - 0.2);
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("recoverData error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (options.battleId) battleId.value = options.battleId;
|
||||
// uni.enableAlertBeforeUnload({
|
||||
// message: "离开比赛可能导致比赛失败,是否继续?",
|
||||
// success: (res) => {
|
||||
// console.log("已启用离开提示");
|
||||
// },
|
||||
// });
|
||||
});
|
||||
|
||||
/**
|
||||
* 检测指定玩家连续 X 环是否达到 3 箭,达到则在环数播报入队后追加 tententen 音效
|
||||
* @param {number|string} playerId - 本次射手的 ID(大乱斗中 ShootResult 保留 playerId)
|
||||
* @param {boolean} isXRing - 本次射击是否为 X 环
|
||||
*/
|
||||
function checkAndPlayTententen(playerId, isXRing) {
|
||||
if (!playerId) return;
|
||||
const id = parseInt(playerId);
|
||||
if (isXRing) {
|
||||
xRingStreaks.value[id] = (xRingStreaks.value[id] || 0) + 1;
|
||||
// 同一玩家连续 3 箭均为 X 环,追加到环数音效队列尾部播放
|
||||
if (xRingStreaks.value[id] >= 3) {
|
||||
xRingStreaks.value[id] = 0;
|
||||
// nextTick 确保 HeaderProgress 的环数播报已入队后再追加 tententen,避免播放顺序颠倒
|
||||
nextTick(() => audioManager.play("tententen", false));
|
||||
}
|
||||
} else {
|
||||
// 非 X 环则重置该玩家的连续计数
|
||||
xRingStreaks.value[id] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||
halfTimeTip.value = false;
|
||||
halfRest.value = false;
|
||||
recoverData(msg);
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
// 更新前快照各玩家本轮已射箭数,用于事后识别本次射手
|
||||
const curRound = playersScores.value[playersScores.value.length - 1] || {};
|
||||
const prevCounts = {};
|
||||
for (const pid of Object.keys(curRound)) {
|
||||
prevCounts[pid] = (curRound[pid] || []).length;
|
||||
}
|
||||
recoverData(msg);
|
||||
// 对比更新后数据找出箭数增加的玩家(即本次射手),并读取其最新箭的 ring 数据
|
||||
const newRound = playersScores.value[playersScores.value.length - 1] || {};
|
||||
let shooterId = null;
|
||||
let isXRing = false;
|
||||
for (const pid of Object.keys(newRound)) {
|
||||
const newLen = (newRound[pid] || []).length;
|
||||
if (newLen > (prevCounts[pid] || 0)) {
|
||||
shooterId = parseInt(pid);
|
||||
const shot = newRound[pid][newLen - 1];
|
||||
isXRing = !!(shot?.ringX && shot?.ring);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 检测同一玩家三箭全 X 环,触发 tententen 音效
|
||||
checkAndPlayTententen(shooterId, isXRing);
|
||||
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
|
||||
halfTimeTip.value = true;
|
||||
halfRest.value = true;
|
||||
tips.value = "准备下半场";
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
setTimeout(() => {
|
||||
// 全部跳转到新结算页
|
||||
uni.redirectTo({
|
||||
url: "/pages/friend-battle-result?battleId=" + msg.matchId,
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: true,
|
||||
});
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
await laserCloseAPI();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: false,
|
||||
});
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
audioManager.stopAll();
|
||||
});
|
||||
|
||||
onShow(async () => {
|
||||
if (battleId.value) {
|
||||
const result = await getBattleAPI(battleId.value);
|
||||
if (!result) return;
|
||||
if (result.status === 2) {
|
||||
uni.showToast({
|
||||
title: "比赛已结束",
|
||||
icon: "none",
|
||||
});
|
||||
uni.navigateBack({
|
||||
delta: 2,
|
||||
});
|
||||
} else {
|
||||
recoverData(result, { force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :title="title" :bgType="1">
|
||||
<view class="container">
|
||||
<BattleHeader v-if="!start" :players="players" />
|
||||
<TestDistance v-if="start === false" :guide="false" :isBattle="true" />
|
||||
<ShootProgress
|
||||
:show="start"
|
||||
:start="start && !halfRest"
|
||||
:tips="tips"
|
||||
:total="90"
|
||||
:melee="true"
|
||||
:battleId="battleId"
|
||||
/>
|
||||
<view v-if="start" class="user-row">
|
||||
<Avatar :src="user.avatar" :size="35" />
|
||||
<BowPower />
|
||||
</view>
|
||||
<BowTarget
|
||||
v-if="start"
|
||||
:currentRound="
|
||||
playersScores.map((s) => s[user.id].length).reduce((a, b) => a + b, 0)
|
||||
"
|
||||
:totalRound="12"
|
||||
:scores="playersScores.map((r) => r[user.id]).flat()"
|
||||
:isSvip="isCurrentUserSvip"
|
||||
:stop="halfRest"
|
||||
/>
|
||||
<view :style="{ paddingBottom: '20px' }">
|
||||
<PlayerScore
|
||||
v-if="start"
|
||||
v-for="(player, index) in playersSorted"
|
||||
:key="index"
|
||||
:player="player"
|
||||
:scores="playersScores.map((s) => s[player.id])"
|
||||
/>
|
||||
</view>
|
||||
<ScreenHint
|
||||
:show="halfTimeTip"
|
||||
mode="small"
|
||||
>
|
||||
<view class="half-time-tip">
|
||||
<text>上半场结束,休息一下吧:)</text>
|
||||
<text>20秒后开始下半场</text>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
<!-- 设备离线提示弹窗 -->
|
||||
<SModal
|
||||
:show="showOfflineModal"
|
||||
:noBg="true"
|
||||
height="360rpx"
|
||||
:onClose="() => (showOfflineModal = false)"
|
||||
>
|
||||
<view class="offline-modal">
|
||||
<text class="offline-title">设备已离线</text>
|
||||
<text class="offline-desc">检测到设备已断开连接,请检查设备后继续比赛</text>
|
||||
<SButton @click="showOfflineModal = false">我知道了</SButton>
|
||||
</view>
|
||||
</SModal>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.offline-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60rpx 40rpx 40rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.offline-title {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #FED847;
|
||||
}
|
||||
|
||||
.offline-desc {
|
||||
font-size: 28rpx;
|
||||
color: #CCCCCC;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -4,28 +4,59 @@ import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import { getGameAPI } from "@/apis";
|
||||
import { getBattleAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const scores = ref([]);
|
||||
const currentUser = ref({});
|
||||
const data = ref({});
|
||||
const onSelect = (userId) => {
|
||||
const user = data.value.players.find((p) => p.playerId === userId);
|
||||
currentUser.value = user;
|
||||
if (user && user.arrowHistory) {
|
||||
scores.value = user.arrowHistory;
|
||||
}
|
||||
};
|
||||
const { user } = storeToRefs(useStore());
|
||||
const currentUser = ref({
|
||||
arrows: [],
|
||||
});
|
||||
const players = ref([]);
|
||||
|
||||
function getRingTotal(arrows = []) {
|
||||
return arrows.reduce((last, next) => last + (Number(next?.ring) || 0), 0);
|
||||
}
|
||||
|
||||
function getScoreLabel(score) {
|
||||
if (!score) return "";
|
||||
return score.ringX ? "X" : score.ring || "";
|
||||
}
|
||||
|
||||
const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
|
||||
const getMemberNicknameClass = (player = {}) => [
|
||||
"player-name",
|
||||
"member-nickname",
|
||||
player.vip === true && player.sVip !== true ? "member-nickname--vip" : "",
|
||||
player.sVip === true ? "member-nickname--svip" : "",
|
||||
];
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (options.battleId) {
|
||||
const result = await getGameAPI(options.battleId);
|
||||
data.value = result;
|
||||
if (result.players && result.players[0]) {
|
||||
onSelect(result.players[0].playerId);
|
||||
}
|
||||
if (!options.battleId) return;
|
||||
const result = await getBattleAPI(options.battleId || "59348111700660224");
|
||||
const plist = result.teams?.[0]?.players || [];
|
||||
players.value = result.resultList.map((item, index) => {
|
||||
const p = plist.find((p) => String(p.id) === String(item.userId));
|
||||
const arrows = Array.from({ length: 12 }, () => ({}));
|
||||
result.rounds.forEach((r, index) => {
|
||||
if (r.shoots[item.userId]) {
|
||||
r.shoots[item.userId].forEach((s, index2) => {
|
||||
arrows[index2 + index * 6] = s;
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
...p,
|
||||
userId: item.userId,
|
||||
rank: index + 1,
|
||||
name: p?.name || item.name,
|
||||
avatar: p?.avatar || item.avatar || "",
|
||||
arrows,
|
||||
};
|
||||
});
|
||||
if (players.value[0]) {
|
||||
currentUser.value = players.value[0];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -33,47 +64,62 @@ onLoad(async (options) => {
|
||||
<template>
|
||||
<Container title="靶纸">
|
||||
<view class="container">
|
||||
<image src="../static/battle-header-melee.png" mode="widthFix" />
|
||||
<view class="players" v-if="data.players">
|
||||
<image
|
||||
src="../static/battle-header-melee.png"
|
||||
mode="widthFix"
|
||||
:style="{ top: '-50rpx' }"
|
||||
/>
|
||||
<view class="players">
|
||||
<view
|
||||
v-for="(player, index) in data.players"
|
||||
v-for="(player, index) in players"
|
||||
:key="index"
|
||||
:style="{
|
||||
width: `${Math.max(100 / data.players.length, 18)}vw`,
|
||||
color: player.playerId === currentUser.playerId ? '#000' : '#fff9',
|
||||
width: `${Math.max(100 / players.length, 18)}vw`,
|
||||
color: player.userId === currentUser.userId ? '#000' : '#fff9',
|
||||
}"
|
||||
@click="() => onSelect(player.playerId)"
|
||||
@click="currentUser = player"
|
||||
>
|
||||
<image
|
||||
v-if="player.playerId === currentUser.playerId"
|
||||
v-if="player.userId === currentUser.userId"
|
||||
src="../static/player-bg2.png"
|
||||
:style="{
|
||||
width: `${Math.max(100 / data.players.length, 18)}vw`,
|
||||
width: `${Math.max(100 / players.length, 18)}vw`,
|
||||
}"
|
||||
class="player-bg"
|
||||
/>
|
||||
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
||||
<text>{{ player.name }}</text>
|
||||
<view v-if="isMember(player)" :class="getMemberNicknameClass(player)">
|
||||
<text class="member-nickname__text">{{ player.name }}</text>
|
||||
<text v-if="player.sVip === true" class="member-nickname__shine">
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else>{{ player.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ marginTop: '10px' }">
|
||||
<BowTarget :scores="scores" />
|
||||
<BowTarget
|
||||
:scores="currentUser.arrows"
|
||||
:isSvip="currentUser.sVip === true"
|
||||
/>
|
||||
</view>
|
||||
<view class="score-text"
|
||||
><text :style="{ color: '#fed847' }">{{ scores.length }}</text
|
||||
><text :style="{ color: '#fed847' }">{{
|
||||
currentUser.arrows.length
|
||||
}}</text
|
||||
>支箭,共<text :style="{ color: '#fed847' }">{{
|
||||
scores.reduce((last, next) => last + next.ring, 0)
|
||||
getRingTotal(currentUser.arrows)
|
||||
}}</text
|
||||
>环</view
|
||||
>
|
||||
<view class="score-row">
|
||||
<view class="score-row" v-if="currentUser.arrows">
|
||||
<view
|
||||
v-for="(score, index) in scores"
|
||||
v-for="(score, index) in currentUser.arrows"
|
||||
:key="index"
|
||||
class="score-item"
|
||||
:style="{ width: '13vw', height: '13vw' }"
|
||||
>
|
||||
{{ score.ring }}
|
||||
{{ getScoreLabel(score) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -97,7 +143,7 @@ onLoad(async (options) => {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin-top: 25px;
|
||||
margin-top: 50rpx;
|
||||
}
|
||||
.players::-webkit-scrollbar {
|
||||
width: 0;
|
||||
@@ -132,6 +178,16 @@ onLoad(async (options) => {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
.players > view > .player-name {
|
||||
margin: 5px 0;
|
||||
width: 80%;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
}
|
||||
.player-name .member-nickname__text,
|
||||
.player-name .member-nickname__shine {
|
||||
font-size: 12px;
|
||||
}
|
||||
.score-text {
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
import ShootProgress from "@/components/ShootProgress.vue";
|
||||
import BattleHeader from "@/components/BattleHeader.vue";
|
||||
import PlayerScore from "@/components/PlayerScore.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import TestDistance from "@/components/TestDistance.vue";
|
||||
import { getCurrentGameAPI } from "@/apis";
|
||||
import { isGameEnded } from "@/util";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const title = ref("大乱斗");
|
||||
const start = ref(false);
|
||||
const startCount = ref(true);
|
||||
const battleId = ref("");
|
||||
const currentRound = ref(1);
|
||||
const power = ref(0);
|
||||
const scores = ref([]);
|
||||
const tips = ref("即将开始...");
|
||||
const players = ref([]);
|
||||
const playersSorted = ref([]);
|
||||
const playersScores = ref({});
|
||||
const halfTimeTip = ref(false);
|
||||
const isEnded = ref(false);
|
||||
|
||||
watch(
|
||||
() => [players.value, playersScores.value],
|
||||
([n_players, n_scores]) => {
|
||||
if (n_players.length) {
|
||||
playersSorted.value = Object.keys(n_scores)
|
||||
.sort((a, b) => n_scores[b].length - n_scores[a].length)
|
||||
.map((pid) => n_players.find((p) => p.id == pid));
|
||||
}
|
||||
},
|
||||
{
|
||||
deep: true, // 添加深度监听
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
function recoverData(battleInfo) {
|
||||
uni.removeStorageSync("last-awake-time");
|
||||
battleId.value = battleInfo.id;
|
||||
players.value = [...battleInfo.blueTeam, ...battleInfo.redTeam];
|
||||
players.value.forEach((p) => {
|
||||
playersScores.value[p.id] = [...p.arrows];
|
||||
if (p.id === user.value.id) scores.value = [...p.arrows];
|
||||
});
|
||||
const remain = Date.now() / 1000 - battleInfo.startTime;
|
||||
console.log(`当前局已进行${remain}秒`);
|
||||
if (battleInfo.status === 0) {
|
||||
if (remain > 0) {
|
||||
setTimeout(() => {
|
||||
uni.$emit("update-timer", 15 - remain);
|
||||
}, 200);
|
||||
}
|
||||
} else {
|
||||
start.value = true;
|
||||
}
|
||||
if (battleInfo.status === 2) {
|
||||
const elapsedTime = (Date.now() - Date.parse(battleInfo.createdAt)) / 1000;
|
||||
console.log("elapsedTime:", elapsedTime);
|
||||
startCount.value = true;
|
||||
// 这里的开始时间不是游戏开始时间,而是上半场或者下半场或者中场的开始时间,还要根据状态来判断
|
||||
tips.value = battleInfo.halfGame
|
||||
? "下半场:请再射6箭"
|
||||
: "上半场:请先射6箭";
|
||||
setTimeout(() => {
|
||||
uni.$emit("update-ramain", 90 - remain);
|
||||
}, 200);
|
||||
} else if (battleInfo.status === 9) {
|
||||
startCount.value = false;
|
||||
tips.value = "准备下半场";
|
||||
setTimeout(() => {
|
||||
uni.$emit("update-ramain", 0);
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (options.gameMode == 1) title.value = "好友约战 - 大乱斗";
|
||||
if (options.gameMode == 2) title.value = "排位赛 - 大乱斗";
|
||||
if (options.battleId) {
|
||||
battleId.value = options.battleId;
|
||||
const players = uni.getStorageSync("melee-players");
|
||||
if (players) {
|
||||
players.value = players;
|
||||
players.value.forEach((p) => {
|
||||
playersScores.value[p.id] = [];
|
||||
});
|
||||
}
|
||||
const battleInfo = uni.getStorageSync("current-battle");
|
||||
if (battleInfo) {
|
||||
recoverData(battleInfo);
|
||||
setTimeout(getCurrentGameAPI, 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.id !== battleId.value) return;
|
||||
if (msg.constructor === MESSAGETYPES.MeleeAllReady) {
|
||||
start.value = true;
|
||||
startCount.value = true;
|
||||
tips.value = scores.value.length
|
||||
? "下半场:请再射6箭"
|
||||
: "上半场:请先射6箭";
|
||||
halfTimeTip.value = false;
|
||||
}
|
||||
if (msg.constructor === MESSAGETYPES.ShootResult) {
|
||||
if (!start.value) getCurrentGameAPI();
|
||||
if (msg.userId === user.value.id) {
|
||||
scores.value.push({ ...msg.target });
|
||||
power.value = msg.target.battery;
|
||||
}
|
||||
playersScores.value[msg.userId].push({ ...msg.target });
|
||||
}
|
||||
if (msg.constructor === MESSAGETYPES.HalfTimeOver) {
|
||||
uni.$emit("update-ramain", 0);
|
||||
[...msg.groupUserStatus.redTeam, ...msg.groupUserStatus.blueTeam].forEach(
|
||||
(player) => {
|
||||
playersScores.value[player.id] = [...player.arrows];
|
||||
if (player.id === user.value.id) scores.value = [...player.arrows];
|
||||
}
|
||||
);
|
||||
startCount.value = false;
|
||||
halfTimeTip.value = true;
|
||||
tips.value = "准备下半场";
|
||||
}
|
||||
if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
isEnded.value = true;
|
||||
uni.setStorageSync("last-battle", msg.endStatus);
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({
|
||||
url: "/pages/battle-result",
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
if (msg.constructor === MESSAGETYPES.BackToGame) {
|
||||
uni.$emit("update-header-loading", false);
|
||||
if (msg.battleInfo) recoverData(msg.battleInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
const onBack = () => {
|
||||
uni.$showHint(2);
|
||||
};
|
||||
onMounted(() => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: true,
|
||||
});
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: false,
|
||||
});
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
});
|
||||
const refreshTimer = ref(null);
|
||||
onShow(async () => {
|
||||
if (battleId.value) {
|
||||
if (!isEnded.value && (await isGameEnded(battleId.value))) return;
|
||||
getCurrentGameAPI();
|
||||
const refreshData = () => {
|
||||
const lastAwakeTime = uni.getStorageSync("last-awake-time");
|
||||
if (lastAwakeTime) {
|
||||
getCurrentGameAPI();
|
||||
} else {
|
||||
clearInterval(refreshTimer.value);
|
||||
}
|
||||
};
|
||||
refreshTimer.value = setInterval(refreshData, 2000);
|
||||
}
|
||||
});
|
||||
onHide(() => {
|
||||
if (refreshTimer.value) clearInterval(refreshTimer.value);
|
||||
uni.setStorageSync("last-awake-time", Date.now());
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :title="title" :bgType="1" :onBack="onBack">
|
||||
<view class="container">
|
||||
<BattleHeader v-if="!start" :players="players" />
|
||||
<TestDistance v-if="!start" :guide="false" :isBattle="true" />
|
||||
<ShootProgress
|
||||
:show="start"
|
||||
:start="start && startCount"
|
||||
:tips="tips"
|
||||
:total="90"
|
||||
:melee="true"
|
||||
:battleId="battleId"
|
||||
/>
|
||||
<view v-if="start" class="user-row">
|
||||
<Avatar :src="user.avatar" :size="35" />
|
||||
<BowPower :power="power" />
|
||||
</view>
|
||||
<BowTarget
|
||||
v-if="start"
|
||||
:currentRound="scores.length"
|
||||
:totalRound="12"
|
||||
:scores="scores"
|
||||
:stop="!startCount"
|
||||
/>
|
||||
<view :style="{ paddingBottom: '20px' }">
|
||||
<PlayerScore
|
||||
v-if="start"
|
||||
v-for="(player, index) in playersSorted"
|
||||
:key="index"
|
||||
:name="player.name"
|
||||
:avatar="player.avatar"
|
||||
:scores="playersScores[player.id] || []"
|
||||
/>
|
||||
</view>
|
||||
<ScreenHint
|
||||
:show="halfTimeTip"
|
||||
mode="small"
|
||||
:onClose="() => (halfTimeTip = false)"
|
||||
>
|
||||
<view class="half-time-tip">
|
||||
<text>上半场结束,休息一下吧:)</text>
|
||||
<text>20秒后开始下半场</text>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
879
src/pages/member/be-vip.vue
Normal file
879
src/pages/member/be-vip.vue
Normal file
@@ -0,0 +1,879 @@
|
||||
<script setup>
|
||||
import { computed, ref, onBeforeUnmount } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import { createOrderAPI, getAppConfig, getHomeData } from "@/apis";
|
||||
import { capsuleHeight } from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const store = useStore();
|
||||
const { user, config } = storeToRefs(store);
|
||||
const { updateConfig, updateUser } = store;
|
||||
|
||||
const currentTypeIndex = ref(1);
|
||||
const selectedPackageIndex = ref(0);
|
||||
const showModal = ref(false);
|
||||
const loadingConfig = ref(false);
|
||||
const paying = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const timer = ref(null);
|
||||
const lastDate = ref(user.value.expiredAt || 0);
|
||||
const maxRefreshTimes = 12;
|
||||
|
||||
// 会员页核心展示数据:视觉、权益、套餐均按蓝湖当前两张设计稿拆分。
|
||||
const memberTypes = [
|
||||
{
|
||||
key: "normal",
|
||||
tab: "VIP",
|
||||
title: "普通会员",
|
||||
prefix: "成为射灵星球",
|
||||
desc: "特享约战竞技次数包、专属会员标识",
|
||||
benefitTitle: "普通会员专属权益",
|
||||
themeClass: "vip-page--normal",
|
||||
heroCard: "https://static.shelingxingqiu.com/shootmini/static/vip/vip-title.png",
|
||||
activeHeroCard: "https://static.shelingxingqiu.com/shootmini/static/vip/vip-title2.png",
|
||||
orderIcon: "../../static/vip/vip-order.png",
|
||||
heroBadge: "../../static/vip/normal-hero-badge.png",
|
||||
buttonClass: "activate-btn--normal",
|
||||
benefits: [
|
||||
{ label: "专属会员标识", icon: "../../static/vip/vip-badge.png" },
|
||||
{ label: "教练点评", icon: "../../static/vip/vip-comment.png" },
|
||||
{ label: "专享VIP客服", icon: "../../static/vip/vip-service.png" },
|
||||
{ label: "排位赛\n每日+20次", icon: "../../static/vip/vip-rank.png" },
|
||||
{ label: "约战\n每日+20次", icon: "../../static/vip/vip-battle.png" },
|
||||
],
|
||||
packages: [
|
||||
{ name: "连续包月", price: "20", original: "35" },
|
||||
{ name: "12个月", price: "300", original: "420" },
|
||||
{ name: "3个月", price: "84", original: "105" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "super",
|
||||
tab: "SVIP",
|
||||
title: "超级会员",
|
||||
prefix: "成为射灵星球",
|
||||
desc: "尊享专属特效、无限制约战竞技、专属会员标识",
|
||||
benefitTitle: "超级会员专属权益",
|
||||
themeClass: "vip-page--super",
|
||||
heroCard: "https://static.shelingxingqiu.com/shootmini/static/vip/svip-title.png",
|
||||
activeHeroCard: "https://static.shelingxingqiu.com/shootmini/static/vip/svip-title2.png",
|
||||
orderIcon: "../../static/vip/svip-order.png",
|
||||
heroBadge: "../../static/vip/super-hero-badge.png",
|
||||
buttonClass: "activate-btn--super",
|
||||
benefits: [
|
||||
{ label: "专属落点标识", icon: "../../static/vip/svip-point.png" },
|
||||
{ label: "专属命中效果", icon: "../../static/vip/svip-hit.png" },
|
||||
{ label: "专属射箭效果", icon: "../../static/vip/svip-arrow.png" },
|
||||
{ label: "专属会员标识", icon: "../../static/vip/svip-badge.png" },
|
||||
{ label: "教练点评", icon: "../../static/vip/svip-comment.png" },
|
||||
{ label: "约战无限制", icon: "../../static/vip/svip-battle.png" },
|
||||
{ label: "排位赛无限制", icon: "../../static/vip/svip-rank.png" },
|
||||
{ label: "专享SVIP客服", icon: "../../static/vip/svip-service.png" },
|
||||
],
|
||||
packages: [
|
||||
{ name: "连续包月", price: "20", original: "35" },
|
||||
{ name: "12个月", price: "300", original: "420" },
|
||||
{ name: "3个月", price: "84", original: "105" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const currentType = computed(() => memberTypes[currentTypeIndex.value]);
|
||||
|
||||
// 后端到期时间可能是秒级时间戳、毫秒级时间戳或日期字符串,这里统一转成毫秒。
|
||||
const toTimestamp = (value) => {
|
||||
if (!value) return 0;
|
||||
const numberValue = Number(value);
|
||||
if (!Number.isNaN(numberValue)) {
|
||||
return numberValue < 1000000000000 ? numberValue * 1000 : numberValue;
|
||||
}
|
||||
const time = new Date(value).getTime();
|
||||
return Number.isNaN(time) ? 0 : time;
|
||||
};
|
||||
|
||||
// 当前卡片只关心本 tab 对应的会员到期时间,普通会员和超级会员互不兜底。
|
||||
const getVipExpiredValue = (type, source = user.value) => {
|
||||
if (!source) return 0;
|
||||
return type.key === "super" ? source.superVipExpiredAt : source.normalVipExpiredAt;
|
||||
};
|
||||
|
||||
// 支付后轮询需要比较最新会员到期时间,兼容旧字段 expiredAt。
|
||||
const getLatestVipExpiredTime = (source = user.value) => {
|
||||
if (!source) return 0;
|
||||
return Math.max(
|
||||
toTimestamp(source.normalVipExpiredAt),
|
||||
toTimestamp(source.superVipExpiredAt),
|
||||
toTimestamp(source.expiredAt)
|
||||
);
|
||||
};
|
||||
|
||||
// 未过期才展示“会员生效中”样式,已过期或无值继续展示未开通样式。
|
||||
const isVipActive = (type) => {
|
||||
return toTimestamp(getVipExpiredValue(type)) > Date.now();
|
||||
};
|
||||
|
||||
// 会员卡片展示完整到期时间。
|
||||
const formatVipDate = (value) => {
|
||||
const timestamp = toTimestamp(value);
|
||||
if (!timestamp) return "";
|
||||
const date = new Date(timestamp);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hour = String(date.getHours()).padStart(2, "0");
|
||||
const minute = String(date.getMinutes()).padStart(2, "0");
|
||||
const second = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
||||
};
|
||||
|
||||
// 会员生效时使用 title2 切图,未生效时沿用原来的开通引导图。
|
||||
const getHeroCard = (type) => {
|
||||
return isVipActive(type) ? type.activeHeroCard : type.heroCard;
|
||||
};
|
||||
|
||||
const getActiveVipExpiredDate = (type) => {
|
||||
return formatVipDate(getVipExpiredValue(type));
|
||||
};
|
||||
|
||||
// 设计稿里的“下期自动续费”按到期日前一天展示。
|
||||
const getActiveVipRenewDate = (type) => {
|
||||
const timestamp = toTimestamp(getVipExpiredValue(type));
|
||||
if (!timestamp) return "";
|
||||
return formatVipDate(timestamp - 24 * 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
const configMenus = computed(() => {
|
||||
return config.value.vipMenus || [];
|
||||
});
|
||||
|
||||
const getMenuName = (item) => {
|
||||
return item.name || item.vipName || item.title || "";
|
||||
};
|
||||
|
||||
const getMenuPrice = (item) => {
|
||||
const value = item.price || item.total || item.amount || item.money;
|
||||
return value ? String(value).replace("¥", "").replace("¥", "") : "";
|
||||
};
|
||||
|
||||
const getMenuType = (item) => {
|
||||
const vipType = Number(item.vipType);
|
||||
if (vipType === 1) return "normal";
|
||||
if (vipType === 2) return "super";
|
||||
|
||||
const type = String(item.type || "").toLowerCase();
|
||||
if (["super", "svip"].includes(type)) return "super";
|
||||
if (["normal", "vip"].includes(type)) return "normal";
|
||||
|
||||
const name = getMenuName(item);
|
||||
if (/超级|SVIP/i.test(name)) return "super";
|
||||
if (/普通|VIP|会员/i.test(name)) return "normal";
|
||||
return "";
|
||||
};
|
||||
|
||||
const getPackageMonths = (name) => {
|
||||
if (/连续包月/.test(name)) return 1;
|
||||
const match = String(name || "").match(/(\d+)\s*个?月/);
|
||||
return match ? Number(match[1]) : 0;
|
||||
};
|
||||
|
||||
const matchPackageSource = (type, pack, index) => {
|
||||
const menus = configMenus.value;
|
||||
const typedMenus = menus.filter((item) => {
|
||||
const menuType = getMenuType(item);
|
||||
if (type.key === "super") return menuType === "super";
|
||||
return menuType === "normal";
|
||||
});
|
||||
const pool = typedMenus.length ? typedMenus : menus;
|
||||
const packMonths = getPackageMonths(pack.name);
|
||||
return (
|
||||
pool.find((item) => getMenuName(item).indexOf(pack.name) !== -1) ||
|
||||
pool.find((item) => packMonths && getPackageMonths(getMenuName(item)) === packMonths) ||
|
||||
pool.find((item) => getMenuPrice(item) === pack.price) ||
|
||||
pool[index]
|
||||
);
|
||||
};
|
||||
|
||||
const getPackages = (type) => {
|
||||
return type.packages.map((item, index) => {
|
||||
const source = matchPackageSource(type, item, index);
|
||||
return {
|
||||
...item,
|
||||
source,
|
||||
name: source ? getMenuName(source) || item.name : item.name,
|
||||
price: source ? getMenuPrice(source) || item.price : item.price,
|
||||
icon: source && source.icon,
|
||||
id: source && source.id,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const currentPackages = computed(() => {
|
||||
return getPackages(currentType.value);
|
||||
});
|
||||
|
||||
const selectedPackage = computed(() => {
|
||||
return currentPackages.value[selectedPackageIndex.value] || currentPackages.value[0];
|
||||
});
|
||||
|
||||
const switchType = (index) => {
|
||||
currentTypeIndex.value = index;
|
||||
selectedPackageIndex.value = 0;
|
||||
};
|
||||
|
||||
const onSwiperChange = (event) => {
|
||||
currentTypeIndex.value = event.detail.current;
|
||||
selectedPackageIndex.value = 0;
|
||||
};
|
||||
|
||||
const selectPackage = (index) => {
|
||||
selectedPackageIndex.value = index;
|
||||
};
|
||||
|
||||
const toPackageDesc = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/vip-intro",
|
||||
});
|
||||
};
|
||||
|
||||
const toOrderPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/orders",
|
||||
});
|
||||
};
|
||||
|
||||
const loadVipConfig = async () => {
|
||||
if (loadingConfig.value || configMenus.value.length) return;
|
||||
loadingConfig.value = true;
|
||||
try {
|
||||
const result = await getAppConfig();
|
||||
if (result) updateConfig(result);
|
||||
} catch (error) {
|
||||
console.log("load vip config error", error);
|
||||
} finally {
|
||||
loadingConfig.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const clearRefreshTimer = () => {
|
||||
if (timer.value) {
|
||||
clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUserAfterPay = () => {
|
||||
clearRefreshTimer();
|
||||
let refreshTimes = 0;
|
||||
// 先记录支付前的最大到期时间,轮询到更大的值就认为会员状态已刷新。
|
||||
lastDate.value = getLatestVipExpiredTime();
|
||||
refreshing.value = true;
|
||||
timer.value = setInterval(async () => {
|
||||
refreshTimes += 1;
|
||||
try {
|
||||
const result = await getHomeData();
|
||||
const latestExpiredAt = getLatestVipExpiredTime(result.user);
|
||||
if (result.user && latestExpiredAt > lastDate.value) {
|
||||
lastDate.value = latestExpiredAt;
|
||||
updateUser(result.user);
|
||||
clearRefreshTimer();
|
||||
refreshing.value = false;
|
||||
} else if (refreshTimes >= maxRefreshTimes) {
|
||||
clearRefreshTimer();
|
||||
refreshing.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("refresh user after pay error", error);
|
||||
if (refreshTimes >= maxRefreshTimes) {
|
||||
clearRefreshTimer();
|
||||
refreshing.value = false;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const onPay = async () => {
|
||||
if (paying.value || refreshing.value) return;
|
||||
if (!user.value.id) {
|
||||
showModal.value = true;
|
||||
return;
|
||||
}
|
||||
if (!configMenus.value.length) {
|
||||
await loadVipConfig();
|
||||
}
|
||||
const vipId = selectedPackage.value && selectedPackage.value.id;
|
||||
if (!vipId) {
|
||||
uni.showToast({
|
||||
title: loadingConfig.value ? "套餐配置加载中" : "套餐暂不可购买",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
paying.value = true;
|
||||
let waitingPayment = false;
|
||||
try {
|
||||
const result = await createOrderAPI(vipId);
|
||||
const params = result?.pay?.order?.jsApi?.params;
|
||||
if (!params?.timeStamp || !params?.nonceStr || !params?.package || !params?.paySign) {
|
||||
uni.showToast({
|
||||
title: "支付参数生成失败",
|
||||
icon: "none",
|
||||
});
|
||||
refreshing.value = false;
|
||||
return;
|
||||
}
|
||||
waitingPayment = true;
|
||||
wx.requestPayment({
|
||||
timeStamp: params.timeStamp, // 微信支付时间戳
|
||||
nonceStr: params.nonceStr, // 微信支付随机串
|
||||
package: params.package, // 预支付交易会话标识
|
||||
paySign: params.paySign, // 微信支付签名
|
||||
signType: params.signType || "RSA",
|
||||
success() {
|
||||
uni.showToast({
|
||||
title: "支付成功",
|
||||
icon: "none",
|
||||
});
|
||||
refreshUserAfterPay();
|
||||
},
|
||||
fail(res) {
|
||||
console.log("pay error", res);
|
||||
if (res.errMsg && res.errMsg.indexOf("cancel") !== -1) return;
|
||||
uni.showToast({
|
||||
title: "支付失败,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
},
|
||||
complete() {
|
||||
paying.value = false;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("create vip order error", error);
|
||||
} finally {
|
||||
if (!waitingPayment) paying.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onShow(loadVipConfig);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearRefreshTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="" :bgType="10" :scroll="false" :showBottom="false">
|
||||
<view class="vip-page" :class="currentType.themeClass">
|
||||
<view class="type-tabs" :style="{ top: capsuleHeight + 'px' }">
|
||||
<view
|
||||
v-for="(item, index) in memberTypes"
|
||||
:key="item.key"
|
||||
class="type-tab"
|
||||
:class="{ 'type-tab--active': currentTypeIndex === index }"
|
||||
@click="switchType(index)"
|
||||
>
|
||||
<text>{{ item.tab }}</text>
|
||||
<view class="type-tab__indicator" />
|
||||
</view>
|
||||
</view>
|
||||
<swiper
|
||||
class="type-swiper"
|
||||
:current="currentTypeIndex"
|
||||
:duration="260"
|
||||
@change="onSwiperChange"
|
||||
>
|
||||
<swiper-item v-for="type in memberTypes" :key="type.key">
|
||||
<scroll-view scroll-y class="type-scroll" :show-scrollbar="false">
|
||||
<view class="vip-content">
|
||||
<view
|
||||
class="hero-card"
|
||||
:class="{ 'hero-card--active': isVipActive(type) }"
|
||||
>
|
||||
<image class="hero-card__bg" :src="getHeroCard(type)" mode="scaleToFill" />
|
||||
<!-- 生效态卡片使用 title2 底图,补充有效期和订单入口叠层。 -->
|
||||
<view v-if="isVipActive(type)" class="hero-card__content">
|
||||
<text class="hero-card__date">
|
||||
有效期至:{{ getActiveVipExpiredDate(type) }}
|
||||
</text>
|
||||
<!-- <text v-if="type.key === 'super'" class="hero-card__renew">
|
||||
下期会员将于:{{ getActiveVipRenewDate(type) }}自动续费
|
||||
</text> -->
|
||||
<view class="hero-card__order" @click.stop="toOrderPage">
|
||||
<image class="hero-card__order-icon" :src="type.orderIcon" mode="aspectFit" />
|
||||
<text>订单管理</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="benefit-title">
|
||||
<view class="benefit-title__line" />
|
||||
<text>{{ type.benefitTitle }}</text>
|
||||
<view class="benefit-title__line" />
|
||||
</view>
|
||||
|
||||
<view class="benefit-grid" :class="{ 'benefit-grid--normal': type.key === 'normal' }">
|
||||
<view
|
||||
v-for="benefit in type.benefits"
|
||||
:key="benefit.label"
|
||||
class="benefit-item"
|
||||
>
|
||||
<view class="benefit-icon">
|
||||
<image class="benefit-icon__img" :src="benefit.icon" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="benefit-item__label">{{ benefit.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="package-header">
|
||||
<text class="package-header__title">选择套餐</text>
|
||||
<view class="package-header__link" @click="toPackageDesc">
|
||||
<text>套餐说明</text>
|
||||
<image
|
||||
class="package-header__icon"
|
||||
src="../../static/enter.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view scroll-x class="package-scroll" :show-scrollbar="false">
|
||||
<view class="package-list">
|
||||
<view
|
||||
v-for="(pack, index) in getPackages(type)"
|
||||
:key="`${type.key}-${pack.name}`"
|
||||
class="package-card"
|
||||
:class="{ 'package-card--active': selectedPackageIndex === index }"
|
||||
@click="selectPackage(index)"
|
||||
>
|
||||
<view class="package-card__inner">
|
||||
<text class="package-name">{{ pack.name }}</text>
|
||||
<view class="package-price">
|
||||
<text class="package-price__symbol">¥</text>
|
||||
<text class="package-price__value">{{ pack.price }}</text>
|
||||
</view>
|
||||
<view class="package-origin">
|
||||
<text>¥{{ pack.original }}</text>
|
||||
<view class="package-origin__line" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<button
|
||||
hover-class="none"
|
||||
class="activate-btn"
|
||||
:class="type.buttonClass"
|
||||
:disabled="loadingConfig || paying || refreshing"
|
||||
@click="onPay"
|
||||
>
|
||||
<text v-if="loadingConfig">加载套餐中</text>
|
||||
<text v-else-if="paying">创建订单中</text>
|
||||
<text v-else-if="!refreshing">¥ {{ selectedPackage.price }} 一键激活</text>
|
||||
<text v-else>刷新会员状态中</text>
|
||||
</button>
|
||||
|
||||
<view class="agreement">
|
||||
<text>支付即同意</text>
|
||||
《 <text class="agreement__link">会员自动续费服务协议</text>》
|
||||
《 <text class="agreement__link">扣款授权服务协议</text>》
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<Signin :show="showModal" :onClose="() => (showModal = false)" />
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vip-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.type-tabs {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
z-index: 998;
|
||||
width: 50%;
|
||||
height: 50px;
|
||||
margin-left: 50%;
|
||||
transform:translateX(-60%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
width: 132rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.type-tab__indicator {
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 4rpx;
|
||||
margin-top: 8rpx;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.type-tab--active {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.vip-page--normal .type-tab--active .type-tab__indicator {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.vip-page--super .type-tab--active {
|
||||
color: #fedab5;
|
||||
}
|
||||
|
||||
.vip-page--super .type-tab--active .type-tab__indicator {
|
||||
background-color: #fedab5;
|
||||
}
|
||||
|
||||
.type-swiper {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.type-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vip-content {
|
||||
min-height: 100%;
|
||||
padding: 32rpx 24rpx 52rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
position: relative;
|
||||
width: 702rpx;
|
||||
height: 260rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-card__bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 702rpx;
|
||||
height: 260rpx;
|
||||
}
|
||||
|
||||
.hero-card__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* 对齐 title2 切图左侧预留文案区域。 */
|
||||
padding: 132rpx 0 0 44rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hero-card__date,
|
||||
.hero-card__renew {
|
||||
color: #8d6d55;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.vip-page--normal .hero-card__date,
|
||||
.vip-page--normal .hero-card__renew {
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.hero-card__order {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 10rpx;
|
||||
color: #6d5644;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.vip-page--normal .hero-card__order {
|
||||
color: #555555;
|
||||
}
|
||||
|
||||
.hero-card__order-icon {
|
||||
width: 28rpx;
|
||||
height: 32rpx;
|
||||
margin-right: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.benefit-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 38rpx;
|
||||
color: #ffffff;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.benefit-title__line {
|
||||
width: 14rpx;
|
||||
height: 2rpx;
|
||||
background-color: #ffffff;
|
||||
margin: 0 10rpx;
|
||||
}
|
||||
|
||||
.benefit-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 622rpx;
|
||||
margin: 38rpx auto 0;
|
||||
}
|
||||
|
||||
.benefit-grid--normal {
|
||||
width: 672rpx;
|
||||
}
|
||||
|
||||
.benefit-item {
|
||||
width: 144rpx;
|
||||
height: 122rpx;
|
||||
margin-right: 94rpx;
|
||||
margin-bottom: 30rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.benefit-item:nth-child(3n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.benefit-grid--normal .benefit-item {
|
||||
margin-right: 120rpx;
|
||||
}
|
||||
|
||||
.benefit-grid--normal .benefit-item:nth-child(3n) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.benefit-icon {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.benefit-icon__img {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.benefit-item__label {
|
||||
margin-top: 8rpx;
|
||||
color: #ffffff;
|
||||
opacity: 0.7;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.package-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 56rpx;
|
||||
padding-right: 8rpx;
|
||||
}
|
||||
|
||||
.benefit-grid--normal + .package-header {
|
||||
margin-top: 146rpx;
|
||||
}
|
||||
|
||||
.package-header__title {
|
||||
color: #ffffff;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vip-page--super .package-header__title {
|
||||
color: #e7ba80;
|
||||
}
|
||||
|
||||
.package-header__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #999999;
|
||||
font-size: 22rpx;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
|
||||
.package-header__icon {
|
||||
width: 24rpx;
|
||||
height: 28rpx;
|
||||
}
|
||||
|
||||
.package-scroll {
|
||||
width: 100%;
|
||||
margin-top: 40rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.package-list {
|
||||
display: flex;
|
||||
width: 984rpx;
|
||||
padding: 6rpx 84rpx 6rpx 6rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.package-card {
|
||||
position: relative;
|
||||
width: 264rpx;
|
||||
height: 224rpx;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #999999;
|
||||
margin-right: 32rpx;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.package-card__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.package-card--active {
|
||||
border-width: 6rpx;
|
||||
border-color: #fed847;
|
||||
}
|
||||
|
||||
.vip-page--super .package-card--active {
|
||||
border: none;
|
||||
padding: 6rpx;
|
||||
background: linear-gradient(180deg, #fef3e6 0%, #f0c191 100%);
|
||||
}
|
||||
|
||||
.vip-page--super .package-card--active .package-card__inner {
|
||||
border-radius: 10rpx;
|
||||
background-color: #050b19;
|
||||
}
|
||||
|
||||
.package-name {
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.package-price {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
margin-top: 12rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.package-card--active .package-price {
|
||||
color: #fed847;
|
||||
}
|
||||
|
||||
.vip-page--super .package-card--active .package-price {
|
||||
color: #ffe8cd;
|
||||
}
|
||||
|
||||
.package-price__symbol {
|
||||
font-size: 30rpx;
|
||||
line-height: 36rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.package-price__value {
|
||||
font-size: 56rpx;
|
||||
line-height: 66rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.package-origin {
|
||||
position: relative;
|
||||
margin-top: 2rpx;
|
||||
color: #999999;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
|
||||
.package-origin__line {
|
||||
position: absolute;
|
||||
left: -4rpx;
|
||||
right: -4rpx;
|
||||
top: 16rpx;
|
||||
height: 2rpx;
|
||||
background-color: #999999;
|
||||
}
|
||||
|
||||
.activate-btn {
|
||||
width: 686rpx;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
margin: 34rpx auto 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #000000;
|
||||
font-size: 30rpx;
|
||||
line-height: 42rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.activate-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.activate-btn--normal {
|
||||
background-color: #fed847;
|
||||
}
|
||||
|
||||
.activate-btn--super {
|
||||
background: linear-gradient( 181deg, #FDFDFC 0%, #FDF8F2 0%, #FFC992 100%, #FFB96C 100%);
|
||||
}
|
||||
|
||||
.agreement {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 24rpx;
|
||||
color: #999999;
|
||||
font-size: 20rpx;
|
||||
line-height: 28rpx;
|
||||
}
|
||||
|
||||
.agreement__link {
|
||||
margin-left: -6rpx;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -61,6 +61,18 @@ const goPay = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const copyOrderId = (orderId) => {
|
||||
uni.setClipboardData({
|
||||
data: String(orderId),
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: "复制成功",
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const cancelOrder = async () => {
|
||||
const result = await cancelOrderListAPI(data.value.orderId);
|
||||
data.value = result;
|
||||
@@ -78,8 +90,13 @@ const cancelOrder = async () => {
|
||||
>
|
||||
<view class="order">
|
||||
<view>
|
||||
<text>商品名:{{ data.vipName }}</text>
|
||||
<text>订单号:{{ data.orderId }}</text>
|
||||
<text>{{ data.vipName }}</text>
|
||||
<view class="order-number">
|
||||
<text>订单号:{{ data.orderId }}</text>
|
||||
<text class="copy-action" @click.stop="copyOrderId(data.orderId)"
|
||||
>复制</text
|
||||
>
|
||||
</view>
|
||||
<text>下单时间:{{ data.vipCreateAt }}</text>
|
||||
<text
|
||||
>支付时间:{{
|
||||
@@ -141,4 +158,25 @@ const cancelOrder = async () => {
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.order-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: #666666;
|
||||
font-size: 26rpx;
|
||||
margin-top: 10rpx;
|
||||
}
|
||||
.order-number > text:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
.copy-action {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
padding: 2rpx 14rpx;
|
||||
color: #1f6ed4;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ScrollList from "@/components/ScrollList.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
import { getOrderListAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { orderStatusNames, getStatusColor } from "@/constants";
|
||||
@@ -10,13 +11,34 @@ import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, config } = storeToRefs(store);
|
||||
|
||||
const autoRenewDialogVisible = ref(false);
|
||||
const selectedRenewOrder = ref(null);
|
||||
|
||||
const toDetailPage = (detail) => {
|
||||
uni.setStorageSync("order", detail);
|
||||
uni.navigateTo({
|
||||
url: `/pages/order-detail`,
|
||||
url: "/pages/member/order-detail",
|
||||
});
|
||||
};
|
||||
|
||||
const openAutoRenewDialog = (detail) => {
|
||||
selectedRenewOrder.value = detail;
|
||||
autoRenewDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const closeAutoRenewDialog = () => {
|
||||
autoRenewDialogVisible.value = false;
|
||||
selectedRenewOrder.value = null;
|
||||
};
|
||||
|
||||
const confirmAutoRenewDialog = () => {
|
||||
autoRenewDialogVisible.value = false;
|
||||
uni.showToast({
|
||||
title: "功能实现中",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
|
||||
const list = ref([]);
|
||||
|
||||
const onLoading = async (page) => {
|
||||
@@ -44,7 +66,7 @@ onShow(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="订单">
|
||||
<Container title="订单管理">
|
||||
<view class="container">
|
||||
<ScrollList :onLoading="onLoading">
|
||||
<view
|
||||
@@ -58,18 +80,31 @@ onShow(() => {
|
||||
>{{ orderStatusNames[item.orderStatus] }}</view
|
||||
>
|
||||
<text>{{ item.vipName }}</text>
|
||||
<!-- <text>订单号:{{ item.orderId }}</text> -->
|
||||
<!-- <text>创建时间:{{ item.vipCreateAt }}</text> -->
|
||||
<text
|
||||
<text>订单号:{{ item.orderId }}</text>
|
||||
<text>创建时间:{{ item.orderCreateAt }}</text>
|
||||
<!-- <text
|
||||
>支付时间:{{
|
||||
item.orderStatus === 4 ? item.paymentTime : ""
|
||||
}}</text
|
||||
>
|
||||
> -->
|
||||
<text>金额:{{ item.total }} 元</text>
|
||||
<text>支付方式:微信</text>
|
||||
<!-- <text>支付方式:微信</text> -->
|
||||
<!-- <text class="renew-action" @click.stop="openAutoRenewDialog(item)">
|
||||
自动续费
|
||||
</text> -->
|
||||
</view>
|
||||
</ScrollList>
|
||||
</view>
|
||||
|
||||
<ModalDialog
|
||||
:show="autoRenewDialogVisible"
|
||||
title=""
|
||||
:content="'确定关闭自动续费吗?\n会员到期后你将失去7项特权哦!'"
|
||||
cancel-text="一意孤行"
|
||||
confirm-text="继续享受"
|
||||
:on-cancel="closeAutoRenewDialog"
|
||||
:on-confirm="confirmAutoRenewDialog"
|
||||
/>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
@@ -78,15 +113,15 @@ onShow(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #f5f5f5;
|
||||
padding-top: 10px;
|
||||
padding-top: 16rpx;
|
||||
}
|
||||
.order-item {
|
||||
position: relative;
|
||||
background-color: #fff;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px;
|
||||
padding: 28rpx 30rpx 18rpx 30rpx;
|
||||
}
|
||||
.order-item > view:first-child {
|
||||
position: absolute;
|
||||
@@ -98,12 +133,20 @@ onShow(() => {
|
||||
font-size: 11px;
|
||||
}
|
||||
.order-item > text:nth-child(2) {
|
||||
color: #000;
|
||||
font-size: 16px;
|
||||
color: #333333;
|
||||
font-size: 30rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
.order-item > text {
|
||||
color: #666666;
|
||||
font-size: 13px;
|
||||
margin-top: 5px;
|
||||
font-size: 26rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.order-item > .renew-action {
|
||||
position: absolute;
|
||||
right: 30rpx;
|
||||
bottom: 18rpx;
|
||||
color: #1f6ed4;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
244
src/pages/member/vip-intro.vue
Normal file
244
src/pages/member/vip-intro.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<script setup>
|
||||
import Container from "@/components/Container.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="会员权益说明">
|
||||
<scroll-view scroll-y class="vip-intro" :show-scrollbar="false">
|
||||
<view class="content">
|
||||
<view class="page-title">射灵星球会员权益</view>
|
||||
|
||||
<view class="paragraph">
|
||||
<text class="strong">核心特权:</text>
|
||||
<text>解锁约战、段位评级、实时排位赛、AI智能教练点评四大核心功能。</text>
|
||||
</view>
|
||||
|
||||
<view class="paragraph">
|
||||
<text class="strong">专属服务:</text>
|
||||
<text>享全年不同阶段VIP专属客服,快速解决技术故障、规则疑问等所有问题。</text>
|
||||
</view>
|
||||
|
||||
<view class="paragraph">
|
||||
<text class="strong">新用户福利:</text>
|
||||
<text>所有初次绑定设备的用户,免费赠送6个月普通会员。</text>
|
||||
</view>
|
||||
|
||||
<view class="paragraph">
|
||||
<text>
|
||||
加入射灵星球,在真实射箭运动中体验在线竞技的乐趣,结识全球志同道合的弓友,持续享受新功能与系统升级,不断挑战自我,创造属于你的辉煌战绩!
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="table-wrap">
|
||||
<view class="intro-toast">
|
||||
<image class="intro-toast__bg" src="../../static/vip/intro-toast.png" mode="scaleToFill" />
|
||||
<text class="intro-toast__text">初次绑定设备赠送6个月</text>
|
||||
</view>
|
||||
<view class="benefit-table">
|
||||
<view class="table-row table-head">
|
||||
<text class="table-cell table-cell--feature">特权</text>
|
||||
<text class="table-cell">基础用户</text>
|
||||
<text class="table-cell">普通会员</text>
|
||||
<text class="table-cell">超级会员</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">专属落点标识</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">螺旋</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">专属命中效果</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">玻璃裂纹</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">箭矢飞行特效</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">光箭</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">每日约战次数</text>
|
||||
<text class="table-cell">2次</text>
|
||||
<text class="table-cell">22次</text>
|
||||
<text class="table-cell">无限</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">每日排位赛次数</text>
|
||||
<text class="table-cell">2次</text>
|
||||
<text class="table-cell">22次</text>
|
||||
<text class="table-cell">无限</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">教练点评</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">专享</text>
|
||||
<text class="table-cell">专享</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">会员标识</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">专享</text>
|
||||
<text class="table-cell">专享</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<text class="table-cell table-cell--feature">专属客服</text>
|
||||
<text class="table-cell">无</text>
|
||||
<text class="table-cell">专享</text>
|
||||
<text class="table-cell">专享</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-title">会员时长叠加与生效规则</view>
|
||||
|
||||
<view class="paragraph">
|
||||
<text class="strong">等级优先级:</text>
|
||||
<text>
|
||||
同时拥有 超级会员 和 普通会员 时,优先使用超级会员权益,普通会员时长自动顺延,待 超级会员 到期后自动生效。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="paragraph">
|
||||
<text class="strong">连续套餐叠加:</text>
|
||||
<text>
|
||||
已有月 / 半年 / 年卡时再购买连续包月 / 包年,总有效期直接累加;连续套餐从下单日起算,下个周期正常自动扣费。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="paragraph example">
|
||||
<text>
|
||||
示例:1 月 1 日买半年卡(7 月 1 日到期),1 月 10 日买连续包月,总有效期延至 8 月 1 日,8 月 1 日会发起首次自动扣款。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="paragraph">
|
||||
<text class="strong">升级超级会员规则:</text>
|
||||
<text>
|
||||
购买升级 超级会员 后立即生效,可升级时长以购买页面提示为准;未升级的剩余 普通会员 时长,将在 超级会员 到期后继续使用。
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vip-intro {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 30rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.page-title,
|
||||
.section-title {
|
||||
color: #333333;
|
||||
font-size: 28rpx;
|
||||
line-height: 40rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin-bottom: 26rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 42rpx 0 22rpx;
|
||||
}
|
||||
|
||||
.paragraph {
|
||||
margin-bottom: 22rpx;
|
||||
color: #333333;
|
||||
font-size: 26rpx;
|
||||
line-height: 38rpx;
|
||||
}
|
||||
|
||||
.strong {
|
||||
color: #333333;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.example {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
position: relative;
|
||||
margin-top: 34rpx;
|
||||
}
|
||||
|
||||
.intro-toast {
|
||||
position: absolute;
|
||||
top: -38rpx;
|
||||
right: 118rpx;
|
||||
z-index: 2;
|
||||
width: 222rpx;
|
||||
height: 54rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.intro-toast__bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 222rpx;
|
||||
height: 54rpx;
|
||||
}
|
||||
|
||||
.intro-toast__text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: #333333;
|
||||
font-size: 18rpx;
|
||||
line-height: 30rpx;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
margin-top: -6rpx;
|
||||
}
|
||||
|
||||
.benefit-table {
|
||||
width: 100%;
|
||||
border-top: 1rpx solid #e5e5e5;
|
||||
border-left: 1rpx solid #e5e5e5;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
min-height: 60rpx;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
width: 22.5%;
|
||||
min-height: 60rpx;
|
||||
padding: 12rpx 4rpx;
|
||||
border-right: 1rpx solid #e5e5e5;
|
||||
border-bottom: 1rpx solid #e5e5e5;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #333333;
|
||||
font-size: 24rpx;
|
||||
line-height: 34rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-cell--feature {
|
||||
width: 32.5%;
|
||||
}
|
||||
|
||||
.table-head .table-cell {
|
||||
color: #666666;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -11,14 +11,15 @@ import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const arrows = ref([]);
|
||||
const isSvip = ref(false);
|
||||
const total = ref(0);
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (options.id) {
|
||||
const result = await getPractiseAPI(options.id);
|
||||
arrows.value = result.arrows;
|
||||
total.value = result.completed_arrows;
|
||||
}
|
||||
if (!options.id) return;
|
||||
const result = await getPractiseAPI(options.id || 176);
|
||||
arrows.value = result.details;
|
||||
isSvip.value = result.sVip === true;
|
||||
total.value = result.details.length;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -35,7 +36,7 @@ onLoad(async (options) => {
|
||||
</view>
|
||||
</view> -->
|
||||
<view :style="{ marginBottom: '20px' }">
|
||||
<BowTarget :scores="arrows" />
|
||||
<BowTarget :scores="arrows" :isSvip="isSvip" />
|
||||
</view>
|
||||
<view class="desc">
|
||||
<text>{{ arrows.length }}</text>
|
||||
@@ -47,7 +48,7 @@ onLoad(async (options) => {
|
||||
:completeEffect="false"
|
||||
:rowCount="total === 12 ? 6 : 9"
|
||||
:total="total"
|
||||
:scores="arrows.map((a) => a.ring)"
|
||||
:arrows="arrows"
|
||||
:margin="arrows.length === 12 ? 4 : 1"
|
||||
:fontSize="arrows.length === 12 ? 25 : 22"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import { bindDeviceAPI, getMyDevicesAPI, unbindDeviceAPI } from "@/apis";
|
||||
import {
|
||||
bindDeviceAPI,
|
||||
getMyDevicesAPI,
|
||||
unbindDeviceAPI,
|
||||
laserAimAPI, bindDeviceAPIV2,
|
||||
} from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const showTip = ref(false);
|
||||
@@ -13,6 +19,10 @@ const store = useStore();
|
||||
const { updateDevice } = store;
|
||||
const { user, device } = storeToRefs(store);
|
||||
const justBind = ref(false);
|
||||
const calibration = ref(false);
|
||||
const token = ref(null);
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||
|
||||
// 扫描二维码方法
|
||||
const handleScan = () => {
|
||||
@@ -23,13 +33,14 @@ const handleScan = () => {
|
||||
scanType: ["qrCode"],
|
||||
success: async (res) => {
|
||||
try {
|
||||
const base64Decode = (str) => {
|
||||
// 将 base64 转换为 utf8 字符串
|
||||
const bytes = wx.base64ToArrayBuffer(str);
|
||||
return String.fromCharCode.apply(null, new Uint8Array(bytes));
|
||||
};
|
||||
|
||||
addDevice.value = JSON.parse(base64Decode(res.result));
|
||||
// const base64Decode = (str) => {
|
||||
// // 将 base64 转换为 utf8 字符串
|
||||
// const bytes = wx.base64ToArrayBuffer(str);
|
||||
// return String.fromCharCode.apply(null, new Uint8Array(bytes));
|
||||
// };
|
||||
//
|
||||
// addDevice.value = JSON.parse(base64Decode(res.result));
|
||||
token.value = res.result;
|
||||
confirmBindTip.value = true;
|
||||
} catch (err) {
|
||||
uni.showToast({
|
||||
@@ -50,8 +61,8 @@ const handleScan = () => {
|
||||
};
|
||||
|
||||
const confirmBind = async () => {
|
||||
if (!justBind.value && addDevice.value.id) {
|
||||
const result = await bindDeviceAPI(addDevice.value);
|
||||
if (!justBind.value && token.value) {
|
||||
const result = await bindDeviceAPIV2(token.value);
|
||||
confirmBindTip.value = false;
|
||||
if (result.binded) {
|
||||
return uni.showToast({
|
||||
@@ -59,7 +70,7 @@ const confirmBind = async () => {
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
updateDevice(addDevice.value.id, addDevice.value.name);
|
||||
updateDevice(result.deviceId, result.name);
|
||||
justBind.value = true;
|
||||
uni.showToast({
|
||||
title: "绑定成功",
|
||||
@@ -76,6 +87,7 @@ const toFristTryPage = () => {
|
||||
|
||||
const unbindDevice = async () => {
|
||||
await unbindDeviceAPI(device.value.deviceId);
|
||||
uni.setStorageSync("calibration", false);
|
||||
uni.showToast({
|
||||
title: "解绑成功",
|
||||
icon: "success",
|
||||
@@ -104,12 +116,23 @@ const copyEmail = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const goCalibration = async () => {
|
||||
await laserAimAPI();
|
||||
uni.navigateTo({
|
||||
url: "/pages/calibration",
|
||||
});
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
calibration.value = uni.getStorageSync("calibration");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="弓箭绑定">
|
||||
<view v-if="!device.deviceId" class="scan-code">
|
||||
<button hover-class="none" @click="handleScan">
|
||||
<button hover-class="none" @click="$clickSound(handleScan)">
|
||||
<image src="../static/scan.png" mode="widthFix" />
|
||||
</button>
|
||||
<button hover-class="none" @click="showTip = true">
|
||||
@@ -137,7 +160,9 @@ const copyEmail = () => {
|
||||
<text>【注】已被绑定的弓箭无法再次绑定。</text>
|
||||
<view>
|
||||
<text>如有任何疑问,请随时联系:</text>
|
||||
<button hover-class="none" @click="copyEmail">shelingxingqiu@163.com</button>
|
||||
<button hover-class="none" @click="copyEmail">
|
||||
shelingxingqiu@163.com
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
@@ -161,10 +186,23 @@ const copyEmail = () => {
|
||||
</ScreenHint>
|
||||
</view>
|
||||
<view v-if="justBind" class="just-bind">
|
||||
<view class="device-binded">
|
||||
<view @click="toDeviceIntroPage">
|
||||
<view
|
||||
class="device-binded"
|
||||
:style="{ marginBottom: calibration ? '250rpx' : '100rpx' }"
|
||||
>
|
||||
<view>
|
||||
<image src="../static/device-icon.png" mode="widthFix" />
|
||||
<text>{{ device.deviceName }}</text>
|
||||
<view class="calibration" v-if="calibration">
|
||||
<button hover-class="none" @click="goCalibration">
|
||||
<text>重新校准</text>
|
||||
<image src="../static/enter-arrow-blue.png" mode="widthFix" />
|
||||
</button>
|
||||
<view>
|
||||
<image src="../static/calibration-tip.png" mode="widthFix" />
|
||||
<text>如有场地/距离变化,需重新校准以保证智能弓射箭精准度</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<image src="../static/bind-success.png" mode="widthFix" />
|
||||
<view>
|
||||
@@ -173,26 +211,67 @@ const copyEmail = () => {
|
||||
mode="widthFix"
|
||||
:style="{ borderRadius: '50%' }"
|
||||
/>
|
||||
<text>{{ user.nickName }}</text>
|
||||
<view
|
||||
:class="[
|
||||
'member-nickname',
|
||||
isVip ? 'member-nickname--vip' : '',
|
||||
isSVip ? 'member-nickname--svip' : '',
|
||||
]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||
<text v-if="isSVip" class="member-nickname__shine">{{
|
||||
user.nickName
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<text>恭喜,你的弓箭和账号已成功绑定!</text>
|
||||
<text :style="{ color: '#fed847' }">已赠送6个月射灵世界会员</text>
|
||||
<text>赶快进入新手试炼场体验一下吧!</text>
|
||||
</view>
|
||||
<SButton :onClick="toFristTryPage">进入新手试炼</SButton>
|
||||
<view :style="{ marginTop: '15px' }">
|
||||
<SButton :onClick="backToHome" backgroundColor="#fff3" color="#fff"
|
||||
<!-- <block v-if="calibration"> -->
|
||||
<view>
|
||||
<text>恭喜,你的弓箭和账号已成功绑定!</text>
|
||||
<text :style="{ color: '#fed847' }">已赠送6个月射灵世界会员</text>
|
||||
</view>
|
||||
<!-- <SButton :onClick="goCalibration" width="60vw" :rounded="40">
|
||||
开启智能弓进行校准
|
||||
</SButton>
|
||||
<text :style="{ marginTop: '20rpx', fontSize: '24rpx', color: '#fff9' }"
|
||||
>校准时弓箭激光将开启,请勿直视激光</text
|
||||
> -->
|
||||
|
||||
<view>
|
||||
<SButton
|
||||
:onClick="backToHome"
|
||||
backgroundColor="#fff3"
|
||||
color="#fff"
|
||||
width="60vw"
|
||||
:rounded="40"
|
||||
>返回首页</SButton
|
||||
>
|
||||
</view>
|
||||
>
|
||||
</view>
|
||||
<view :style="{ marginTop: '15px' }">
|
||||
<SButton :onClick="toFristTryPage" width="60vw" :rounded="40">进入新手试炼</SButton>
|
||||
</view>
|
||||
<!-- </block> -->
|
||||
<!-- <block v-else>
|
||||
|
||||
</block> -->
|
||||
</view>
|
||||
<view v-if="device.deviceId && !justBind" class="has-device">
|
||||
<view class="device-binded">
|
||||
<view @click="toDeviceIntroPage">
|
||||
<view>
|
||||
<image src="../static/device-icon.png" mode="widthFix" />
|
||||
<text>{{ device.deviceName }}</text>
|
||||
<view class="calibration">
|
||||
<button hover-class="none" @click="goCalibration">
|
||||
<text>去校准</text>
|
||||
<image src="../static/enter-arrow-blue.png" mode="widthFix" />
|
||||
</button>
|
||||
<view>
|
||||
<image src="../static/calibration-tip.png" mode="widthFix" />
|
||||
<text
|
||||
>首次绑定智能弓或场地/距离变化时,应进行校准以确保射箭精度</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<image src="../static/bind.png" mode="widthFix" />
|
||||
<view>
|
||||
@@ -201,10 +280,25 @@ const copyEmail = () => {
|
||||
mode="widthFix"
|
||||
:style="{ borderRadius: '50%' }"
|
||||
/>
|
||||
<text>{{ user.nickName }}</text>
|
||||
<view
|
||||
:class="[
|
||||
'member-nickname',
|
||||
isVip ? 'member-nickname--vip' : '',
|
||||
isSVip ? 'member-nickname--svip' : '',
|
||||
]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||
<text v-if="isSVip" class="member-nickname__shine">{{
|
||||
user.nickName
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<SButton :onClick="unbindDevice">解绑</SButton>
|
||||
<view :style="{ marginTop: '240rpx' }">
|
||||
<SButton :onClick="() => $clickSound(unbindDevice)" width="80vw" :rounded="40"
|
||||
>解绑</SButton
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
@@ -313,18 +407,20 @@ const copyEmail = () => {
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
margin: 100px 0;
|
||||
margin-top: 200rpx;
|
||||
}
|
||||
.device-binded > view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.device-binded > view > image {
|
||||
width: 24vw;
|
||||
height: 24vw;
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
margin-bottom: 5px;
|
||||
border-radius: 10px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.device-binded > view > text {
|
||||
width: 120px;
|
||||
@@ -333,8 +429,17 @@ const copyEmail = () => {
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
.device-binded .member-nickname {
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
}
|
||||
.device-binded .member-nickname__text,
|
||||
.device-binded .member-nickname__shine {
|
||||
font-size: 26rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.device-binded > image {
|
||||
width: 16vw;
|
||||
width: 100rpx;
|
||||
margin: 0 20px;
|
||||
}
|
||||
.has-device,
|
||||
@@ -347,11 +452,42 @@ const copyEmail = () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
margin: 75px 0;
|
||||
font-size: 28rpx;
|
||||
margin-bottom: 100rpx;
|
||||
}
|
||||
.has-device > view:nth-child(2) > text,
|
||||
.just-bind > view:nth-child(2) > text {
|
||||
margin: 5px;
|
||||
}
|
||||
.calibration {
|
||||
position: absolute;
|
||||
bottom: -145rpx;
|
||||
left: 20rpx;
|
||||
}
|
||||
.calibration > button {
|
||||
font-size: 26rpx;
|
||||
color: #287fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: 15rpx;
|
||||
padding-left: 50rpx;
|
||||
}
|
||||
.calibration > button > image {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
}
|
||||
.calibration > view {
|
||||
position: relative;
|
||||
font-size: 22rpx;
|
||||
color: #fff9;
|
||||
padding-top: 34rpx;
|
||||
padding-left: 35rpx;
|
||||
width: 322rpx;
|
||||
}
|
||||
.calibration > view > image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 370rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import BowData from "@/components/BowData.vue";
|
||||
@@ -17,7 +18,7 @@ const practiseList = ref([]);
|
||||
|
||||
const toMatchDetail = (id) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/match-detail?id=${id}`,
|
||||
url: `/pages/match-detail?battleId=${id}`,
|
||||
});
|
||||
};
|
||||
const getPractiseDetail = async (id) => {
|
||||
@@ -52,10 +53,32 @@ const onPractiseLoading = async (page) => {
|
||||
}
|
||||
return result.length;
|
||||
};
|
||||
const getName = (battle) => {
|
||||
if (battle.mode <= 3) return `${battle.mode}V${battle.mode}`;
|
||||
// 排位赛大乱斗:mode 数字与实际人数不一致,使用固定映射
|
||||
if (battle.way === 2) {
|
||||
if (battle.mode === 4) return "5人大乱斗";
|
||||
if (battle.mode === 5) return "10人大乱斗";
|
||||
}
|
||||
// 好友约战大乱斗:从 teams[0].players 取实际参与人数动态展示
|
||||
const count = battle.teams?.[0]?.players?.length;
|
||||
return count ? `${count}人大乱斗` : "大乱斗";
|
||||
};
|
||||
|
||||
/**
|
||||
* 支持通过 URL 参数指定初始 tab
|
||||
* @example /pages/my-growth?tab=1 跳转到「好友约战」tab
|
||||
*/
|
||||
onLoad((options) => {
|
||||
if (options && options.tab !== undefined) {
|
||||
const tabIndex = parseInt(options.tab, 10);
|
||||
if (!isNaN(tabIndex)) selectedIndex.value = tabIndex;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container title="我的成长脚印" overflow="hidden">
|
||||
<Container title="我的成长脚印" :scroll="false">
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="(rankType, index) in ['排位赛', '好友约战', '个人练习']"
|
||||
@@ -70,66 +93,74 @@ const onPractiseLoading = async (page) => {
|
||||
</view>
|
||||
</view>
|
||||
<view class="contents">
|
||||
<ScrollList :show="selectedIndex === 0" :onLoading="onMatchLoading">
|
||||
<view
|
||||
v-for="(item, index) in matchList"
|
||||
:key="index"
|
||||
@click="() => toMatchDetail(item.battleId)"
|
||||
>
|
||||
<view class="contest-header">
|
||||
<text>{{ item.name }}</text>
|
||||
<text>{{ item.createdAt }}</text>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
<BattleHeader
|
||||
:players="item.mode === 1 ? [] : item.players"
|
||||
:blueTeam="item.bluePlayers"
|
||||
:redTeam="item.redPlayers"
|
||||
:winner="item.winner"
|
||||
:showRank="item.mode === 2"
|
||||
:showHeader="false"
|
||||
/>
|
||||
</view>
|
||||
</ScrollList>
|
||||
<ScrollList :show="selectedIndex === 1" :onLoading="onBattleLoading">
|
||||
<view
|
||||
v-for="(item, index) in battleList"
|
||||
:key="index"
|
||||
@click="() => toMatchDetail(item.battleId)"
|
||||
>
|
||||
<view class="contest-header">
|
||||
<text>{{ item.name }}</text>
|
||||
<text>{{ item.createdAt }}</text>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
<BattleHeader
|
||||
:players="item.mode === 1 ? [] : item.players"
|
||||
:blueTeam="item.bluePlayers"
|
||||
:redTeam="item.redPlayers"
|
||||
:winner="item.winner"
|
||||
:showRank="item.mode === 2"
|
||||
:showHeader="false"
|
||||
/>
|
||||
</view>
|
||||
</ScrollList>
|
||||
<ScrollList
|
||||
:show="selectedIndex === 2"
|
||||
:onLoading="onPractiseLoading"
|
||||
:pageSize="15"
|
||||
<swiper
|
||||
:current="selectedIndex"
|
||||
@change="(e) => (selectedIndex = e.detail.current)"
|
||||
:style="{ height: '100%' }"
|
||||
>
|
||||
<view
|
||||
v-for="(item, index) in practiseList"
|
||||
:key="index"
|
||||
class="practice-record"
|
||||
@click="() => getPractiseDetail(item.id)"
|
||||
>
|
||||
<text
|
||||
>{{ item.completed_arrows === 36 ? "耐力挑战" : "单组练习" }}
|
||||
{{ item.createdAt }}</text
|
||||
>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
</ScrollList>
|
||||
<swiper-item>
|
||||
<ScrollList :onLoading="onMatchLoading">
|
||||
<view
|
||||
v-for="(item, index) in matchList"
|
||||
:key="index"
|
||||
@click="() => toMatchDetail(item.id)"
|
||||
>
|
||||
<view class="contest-header">
|
||||
<text>{{ getName(item) }}</text>
|
||||
<text>{{ item.createTime }}</text>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
<BattleHeader
|
||||
:players="item.teams[0] ? item.teams[0].players : []"
|
||||
:blueTeam="item.teams[1] ? item.teams[1].players : []"
|
||||
:redTeam="item.teams[2] ? item.teams[2].players : []"
|
||||
:winner="item.winTeam"
|
||||
:showRank="!!item.teams[0]"
|
||||
:showHeader="false"
|
||||
/>
|
||||
</view>
|
||||
</ScrollList>
|
||||
</swiper-item>
|
||||
<swiper-item>
|
||||
<ScrollList :onLoading="onBattleLoading">
|
||||
<view
|
||||
v-for="(item, index) in battleList"
|
||||
:key="index"
|
||||
@click="() => toMatchDetail(item.id)"
|
||||
>
|
||||
<view class="contest-header">
|
||||
<text>{{ getName(item) }}</text>
|
||||
<text>{{ item.createTime }}</text>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
<BattleHeader
|
||||
:players="item.teams[0] ? item.teams[0].players : []"
|
||||
:blueTeam="item.teams[1] ? item.teams[1].players : []"
|
||||
:redTeam="item.teams[2] ? item.teams[2].players : []"
|
||||
:winner="item.winTeam"
|
||||
:showRank="!!item.teams[0]"
|
||||
:showHeader="false"
|
||||
/>
|
||||
</view>
|
||||
</ScrollList>
|
||||
</swiper-item>
|
||||
<swiper-item>
|
||||
<ScrollList :onLoading="onPractiseLoading" :pageSize="15">
|
||||
<view
|
||||
v-for="(item, index) in practiseList"
|
||||
:key="index"
|
||||
class="practice-record"
|
||||
@click="() => getPractiseDetail(item.id)"
|
||||
>
|
||||
<text
|
||||
>{{ item.completed_arrows === 36 ? "耐力挑战" : "单组练习" }}
|
||||
{{ item.createTime }}</text
|
||||
>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
</ScrollList>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
66
src/pages/my-like-list.vue
Normal file
66
src/pages/my-like-list.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import ScrollList from "@/components/ScrollList.vue";
|
||||
import { getMyLikeList } from "@/apis";
|
||||
|
||||
const list = ref([]);
|
||||
|
||||
const onListLoading = async (page) => {
|
||||
const result = await getMyLikeList(page);
|
||||
if (page === 1) list.value = result.list;
|
||||
else list.value = list.value.concat(result.list);
|
||||
return result.list.length;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container
|
||||
:bgType="2"
|
||||
bgColor="#F5F5F5"
|
||||
:whiteBackArrow="false"
|
||||
title="赞我的朋友"
|
||||
>
|
||||
<view class="container">
|
||||
<ScrollList :onLoading="onListLoading">
|
||||
<block v-for="item in list" :key="item.id">
|
||||
<view class="like-item">
|
||||
<Avatar :src="item.avatar" mode="widthFix" />
|
||||
<text>{{ item.name }}</text>
|
||||
</view>
|
||||
<view class="like-bottom-line" />
|
||||
</block>
|
||||
</ScrollList>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.like-item {
|
||||
background: $uni-white;
|
||||
height: 140rpx;
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: 0 25rpx;
|
||||
}
|
||||
.like-item > text {
|
||||
margin-left: 25rpx;
|
||||
}
|
||||
.like-bottom-line {
|
||||
width: calc(100% - 50rpx);
|
||||
margin: 0 25rpx;
|
||||
height: 1rpx;
|
||||
background: #e5e5e5;
|
||||
}
|
||||
</style>
|
||||
@@ -43,13 +43,13 @@ const toEditPage = () => {
|
||||
bowtargetType.value &&
|
||||
amountGroup.value
|
||||
) {
|
||||
uni.setStorageSync("point-book", {
|
||||
uni.setStorageSync("last-point-book", {
|
||||
bowType: bowType.value,
|
||||
distance: distance.value,
|
||||
bowtargetType: bowtargetType.value,
|
||||
amountGroup: amountGroup.value,
|
||||
});
|
||||
uni.navigateTo({
|
||||
uni.redirectTo({
|
||||
url: "/pages/point-book-edit",
|
||||
});
|
||||
} else {
|
||||
@@ -67,7 +67,7 @@ const toEditPage = () => {
|
||||
// }
|
||||
// });
|
||||
onMounted(async () => {
|
||||
const pointBook = uni.getStorageSync("point-book");
|
||||
const pointBook = uni.getStorageSync("last-point-book");
|
||||
if (pointBook) {
|
||||
bowType.value = pointBook.bowType;
|
||||
distance.value = pointBook.distance;
|
||||
@@ -85,59 +85,37 @@ onMounted(async () => {
|
||||
title="选择参数"
|
||||
>
|
||||
<view class="container">
|
||||
<!-- <view class="header">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-08-06/dbv8w5ak76hozbfpy2.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view>
|
||||
<view>
|
||||
<text>{{ days }}</text>
|
||||
<text>天</text>
|
||||
</view>
|
||||
<text>训练天数</text>
|
||||
</view>
|
||||
<view>
|
||||
<view>
|
||||
<text>{{ arrows }}</text>
|
||||
<text>箭</text>
|
||||
</view>
|
||||
<text>训练箭数</text>
|
||||
</view>
|
||||
</view> -->
|
||||
<view>
|
||||
<EditOption
|
||||
:itemIndex="0"
|
||||
:expand="expandIndex === 0"
|
||||
:onExpand="onExpandChange"
|
||||
:onSelect="onSelect"
|
||||
:value="bowType.name"
|
||||
/>
|
||||
<EditOption
|
||||
:itemIndex="1"
|
||||
:expand="expandIndex === 1"
|
||||
:onExpand="onExpandChange"
|
||||
:onSelect="onSelect"
|
||||
:value="distance + ''"
|
||||
/>
|
||||
<EditOption
|
||||
:itemIndex="2"
|
||||
:expand="expandIndex === 2"
|
||||
:onExpand="onExpandChange"
|
||||
:onSelect="onSelect"
|
||||
:value="bowtargetType.name"
|
||||
/>
|
||||
<EditOption
|
||||
:itemIndex="3"
|
||||
:expand="expandIndex === 3"
|
||||
:onExpand="onExpandChange"
|
||||
:onSelect="onSelect"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ marginBottom: '20px' }">
|
||||
<SButton :rounded="50" :onClick="toEditPage">下一步</SButton>
|
||||
<EditOption
|
||||
:itemIndex="0"
|
||||
:expand="expandIndex === 0"
|
||||
:onExpand="onExpandChange"
|
||||
:onSelect="onSelect"
|
||||
:value="bowType.name"
|
||||
/>
|
||||
<EditOption
|
||||
:itemIndex="1"
|
||||
:expand="expandIndex === 1"
|
||||
:onExpand="onExpandChange"
|
||||
:onSelect="onSelect"
|
||||
:value="distance + ''"
|
||||
/>
|
||||
<EditOption
|
||||
:itemIndex="2"
|
||||
:expand="expandIndex === 2"
|
||||
:onExpand="onExpandChange"
|
||||
:onSelect="onSelect"
|
||||
:value="bowtargetType.name"
|
||||
/>
|
||||
<EditOption
|
||||
:itemIndex="3"
|
||||
:expand="expandIndex === 3"
|
||||
:onExpand="onExpandChange"
|
||||
:onSelect="onSelect"
|
||||
/>
|
||||
</view>
|
||||
<template #bottom>
|
||||
<SButton :rounded="50" :onClick="() => $clickSound(toEditPage)">下一步</SButton>
|
||||
</template>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
|
||||
434
src/pages/point-book-detail-share.vue
Normal file
434
src/pages/point-book-detail-share.vue
Normal file
@@ -0,0 +1,434 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import BowTargetEdit from "@/components/BowTargetEdit.vue";
|
||||
import ScreenHint2 from "@/components/ScreenHint2.vue";
|
||||
import RingBarChart from "@/components/RingBarChart.vue";
|
||||
import { getPointBookDetailAPI, getPointBookConfigAPI } from "@/apis";
|
||||
|
||||
const selectedIndex = ref(0);
|
||||
const showTip = ref(false);
|
||||
const showTip2 = ref(false);
|
||||
const data = ref({});
|
||||
const targetId = ref(0);
|
||||
const targetSrc = ref("");
|
||||
const arrows = ref([]);
|
||||
const record = ref({
|
||||
groups: [],
|
||||
user: {},
|
||||
});
|
||||
const bowConfig = ref({});
|
||||
|
||||
const paddingTop = computed(() => {
|
||||
const menuBtnInfo = uni.getMenuButtonBoundingClientRect();
|
||||
return menuBtnInfo.top - 9 - 9;
|
||||
});
|
||||
|
||||
const openTip = (index) => {
|
||||
if (index === 1) showTip.value = true;
|
||||
else if (index === 2) showTip2.value = true;
|
||||
};
|
||||
|
||||
const closeTip = () => {
|
||||
showTip.value = false;
|
||||
showTip2.value = false;
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
const currentPage = pages[pages.length - 2];
|
||||
uni.navigateBack({
|
||||
delta: currentPage.route === "pages/point-book" ? 1 : 2,
|
||||
});
|
||||
} else {
|
||||
uni.redirectTo({
|
||||
url: "/pages/index",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
uni.redirectTo({
|
||||
url: "/pages/point-book",
|
||||
});
|
||||
};
|
||||
|
||||
const ringRates = computed(() => {
|
||||
const rates = new Array(12).fill(0);
|
||||
arrows.value.forEach((item) => {
|
||||
if (item.ring === -1) rates[11] += 1;
|
||||
else rates[item.ring] += 1;
|
||||
});
|
||||
return rates.map((r) => r / arrows.value.length);
|
||||
});
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (options.id) {
|
||||
const result = await getPointBookDetailAPI(options.id || 183);
|
||||
record.value = result;
|
||||
const config = await getPointBookConfigAPI();
|
||||
bowConfig.value = config;
|
||||
config.targetOption.some((item) => {
|
||||
if (item.id === result.targetType) {
|
||||
targetId.value = item.id;
|
||||
targetSrc.value = item.icon;
|
||||
}
|
||||
});
|
||||
if (result.groups) {
|
||||
data.value = result.groups[0];
|
||||
arrows.value = result.groups[0].list;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const bowOptionName = computed(() => {
|
||||
if (bowConfig.value.bowOption && record.value.bowType) {
|
||||
const data = bowConfig.value.bowOption.find(
|
||||
(b) => b.id === record.value.bowType
|
||||
);
|
||||
if (data) return data.name || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const targetTypeName = computed(() => {
|
||||
if (bowConfig.value.targetOption && record.value.targetType) {
|
||||
const data = bowConfig.value.targetOption.find(
|
||||
(b) => b.id === record.value.targetType
|
||||
);
|
||||
if (data) return data.name || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container" :style="{ paddingTop: paddingTop + 'px' }">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg5.png"
|
||||
class="bg-image"
|
||||
mode="aspectFill"
|
||||
:style="{ height: paddingTop + 60 + 'px' }"
|
||||
/>
|
||||
<view class="header">
|
||||
<image
|
||||
:src="record.user.avatar || '../static/user-icon.png'"
|
||||
mode="widthFix"
|
||||
class="avatar"
|
||||
/>
|
||||
<view>
|
||||
<text>{{ record.user.name }}</text>
|
||||
<view class="point-book-info">
|
||||
<text v-if="bowOptionName">{{ bowOptionName }}</text>
|
||||
<text>{{ record.distance }} 米</text>
|
||||
<text v-if="targetTypeName">{{ targetTypeName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="detail-data">
|
||||
<view>
|
||||
<view
|
||||
:style="{ display: 'flex', alignItems: 'center' }"
|
||||
@click="() => openTip(1)"
|
||||
>
|
||||
<text>落点稳定性</text>
|
||||
<image
|
||||
src="../static/s-question-mark.png"
|
||||
mode="widthFix"
|
||||
class="question-mark"
|
||||
/>
|
||||
</view>
|
||||
<text>{{ Number((data.stability || 0).toFixed(2)) }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<view>黄心率</view>
|
||||
<text>{{ Number((data.yellowRate * 100).toFixed(2)) }}%</text>
|
||||
</view>
|
||||
<view>
|
||||
<view>10环数</view>
|
||||
<text>{{ data.tenRings }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<view>平均环数</view>
|
||||
<text>{{ Number((data.averageRing || 0).toFixed(2)) }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<view>总环数</view>
|
||||
<text>{{ data.userTotalRing }}/{{ data.totalRing }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="title-bar">
|
||||
<view />
|
||||
<text>落点分布</text>
|
||||
<!-- <button hover-class="none" @click="() => openTip(2)">
|
||||
<image
|
||||
src="../static/s-question-mark.png"
|
||||
mode="widthFix"
|
||||
class="question-mark"
|
||||
/>
|
||||
</button> -->
|
||||
</view>
|
||||
<view :style="{ transform: 'translateY(-45rpx)' }">
|
||||
<BowTargetEdit
|
||||
:id="targetId"
|
||||
:src="targetSrc"
|
||||
:arrows="arrows.filter((item) => item.x && item.y)"
|
||||
/>
|
||||
</view>
|
||||
<view :style="{ transform: 'translateY(-60rpx)' }">
|
||||
<!-- <view class="title-bar">
|
||||
<view />
|
||||
<text>环值分布</text>
|
||||
</view> -->
|
||||
<view :style="{ padding: '0 30rpx' }">
|
||||
<RingBarChart :data="ringRates" />
|
||||
</view>
|
||||
<!-- <view class="title-bar" :style="{ marginTop: '30rpx' }">
|
||||
<view />
|
||||
<text>{{
|
||||
selectedIndex === 0 ? "每组环数" : `第${selectedIndex}组环数`
|
||||
}}</text>
|
||||
</view> -->
|
||||
<view class="ring-text-groups">
|
||||
<view v-for="(item, index) in record.groups" :key="index">
|
||||
<view v-if="selectedIndex === 0 && index !== 0">
|
||||
<text>{{ index }}:</text>
|
||||
<text>{{ item.list.reduce((acc, cur) => acc + cur.ring, 0) }}</text>
|
||||
<text>环</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="
|
||||
(selectedIndex === 0 && index !== 0) ||
|
||||
(selectedIndex !== 0 && index === selectedIndex)
|
||||
"
|
||||
:style="{
|
||||
marginLeft: selectedIndex === 0 && index !== 0 ? '20rpx' : '0',
|
||||
}"
|
||||
>
|
||||
<text
|
||||
v-for="(arrow, index2) in item.list"
|
||||
:key="index2"
|
||||
:style="{
|
||||
color:
|
||||
arrow.ring === 0 || arrow.ring === 10 ? '#FFA118' : '#666',
|
||||
}"
|
||||
>
|
||||
{{
|
||||
arrow.ring === 0 ? "X" : arrow.ring === -1 ? "M" : arrow.ring
|
||||
}}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<SButton :onClick="goHome" :rounded="40">开启我的弓箭记录</SButton>
|
||||
</view>
|
||||
<ScreenHint2 :show="showTip || showTip2" :onClose="closeTip">
|
||||
<view class="tip-content">
|
||||
<block v-if="showTip">
|
||||
<text>落点稳定性说明</text>
|
||||
<text
|
||||
>通过计算每支箭与其他箭的平均距离衡量射箭的稳定性,数字越小则说明射箭越稳定。该数据只能在用户标记落点的情况下生成。</text
|
||||
>
|
||||
</block>
|
||||
<block v-if="showTip2">
|
||||
<text>落点分布说明</text>
|
||||
<text>展示用户某次练习中射箭的点位</text>
|
||||
</block>
|
||||
</view>
|
||||
</ScreenHint2>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
}
|
||||
.header {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
.header > image {
|
||||
border-radius: 50%;
|
||||
width: 90rpx;
|
||||
height: 90rpx;
|
||||
border: 2rpx solid #000;
|
||||
margin: 0 25rpx;
|
||||
}
|
||||
.header > view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.header > view > text {
|
||||
color: #000;
|
||||
margin-bottom: 7rpx;
|
||||
}
|
||||
.bg-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.header {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
}
|
||||
.detail-data {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
column-gap: 3vw;
|
||||
margin: 10rpx 30rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.detail-data > view,
|
||||
.detail-data > button {
|
||||
border-radius: 10px;
|
||||
background-color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
padding: 15rpx 24rpx;
|
||||
}
|
||||
.detail-data > view > view {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
.detail-data > view > view > text {
|
||||
word-break: keep-all;
|
||||
}
|
||||
.detail-data > view > text {
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
}
|
||||
.detail-data > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
color: #999999;
|
||||
}
|
||||
.detail-data > button > image {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-right: 10rpx;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
.question-mark {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
margin-left: 3px;
|
||||
}
|
||||
.title-bar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
.title-bar > view:first-child {
|
||||
width: 8rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 10px;
|
||||
background-color: #fed847;
|
||||
margin-right: 7px;
|
||||
margin-left: 15px;
|
||||
}
|
||||
.title-bar > button {
|
||||
height: 34rpx;
|
||||
}
|
||||
.tip-content {
|
||||
width: 100%;
|
||||
padding: 50rpx 44rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #000;
|
||||
}
|
||||
.tip-content > text {
|
||||
width: 100%;
|
||||
}
|
||||
.tip-content > text:first-child {
|
||||
text-align: center;
|
||||
}
|
||||
.tip-content > text:last-child {
|
||||
font-size: 13px;
|
||||
margin-top: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ring-text-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20rpx;
|
||||
padding-top: 40rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
}
|
||||
.ring-text-groups > view {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.ring-text-groups > view > view:first-child:nth-last-child(2) {
|
||||
margin-top: 10rpx;
|
||||
margin-left: 30rpx;
|
||||
width: 90rpx;
|
||||
text-align: center;
|
||||
justify-content: flex-end;
|
||||
font-size: 20rpx;
|
||||
display: flex;
|
||||
color: #999;
|
||||
}
|
||||
.ring-text-groups > view > view:first-child:nth-last-child(2) > text {
|
||||
line-height: 30rpx;
|
||||
}
|
||||
.ring-text-groups
|
||||
> view
|
||||
> view:first-child:nth-last-child(2)
|
||||
> text:nth-child(2) {
|
||||
font-size: 40rpx;
|
||||
/* min-width: 45rpx; */
|
||||
color: #666;
|
||||
margin-right: 6rpx;
|
||||
margin-top: -5rpx;
|
||||
}
|
||||
.ring-text-groups > view > view:last-child {
|
||||
width: 80%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 30rpx;
|
||||
transform: translateX(20rpx);
|
||||
}
|
||||
.ring-text-groups > view > view:last-child > text {
|
||||
width: 16.6%;
|
||||
text-align: center;
|
||||
margin-bottom: 10rpx;
|
||||
font-weight: 500;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.notes-input {
|
||||
width: calc(100% - 40rpx);
|
||||
margin: 25rpx 0;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
color: #000;
|
||||
padding: 20rpx;
|
||||
}
|
||||
.point-book-info {
|
||||
color: #333;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.point-book-info > text {
|
||||
border-radius: 6px;
|
||||
background-color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +1,70 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import BowTargetEdit from "@/components/BowTargetEdit.vue";
|
||||
import ScreenHint2 from "@/components/ScreenHint2.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import RingBarChart from "@/components/RingBarChart.vue";
|
||||
|
||||
import { getPointBookDetailAPI } from "@/apis";
|
||||
import { getPointBookDetailAPI, addNoteAPI } from "@/apis";
|
||||
import { wxShare } from "@/util";
|
||||
import { generateShareImage, generateShareCard } from "@/canvas";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, device } = storeToRefs(store);
|
||||
|
||||
const selectedIndex = ref(0);
|
||||
const showTip = ref(false);
|
||||
const showTip2 = ref(false);
|
||||
const groups = ref([]);
|
||||
const showTip3 = ref(false);
|
||||
const data = ref({});
|
||||
const targetId = ref(0);
|
||||
const targetSrc = ref("");
|
||||
const arrows = ref([]);
|
||||
const notes = ref("");
|
||||
const hasPoint = ref(false);
|
||||
const record = ref({
|
||||
groups: [],
|
||||
user: {},
|
||||
});
|
||||
const shareType = ref(1);
|
||||
|
||||
const openTip = (index) => {
|
||||
if (index === 1) showTip.value = true;
|
||||
else if (index === 2) showTip2.value = true;
|
||||
else if (index === 3) showTip3.value = true;
|
||||
};
|
||||
|
||||
const closeTip = () => {
|
||||
showTip.value = false;
|
||||
showTip2.value = false;
|
||||
showTip3.value = false;
|
||||
};
|
||||
|
||||
const saveNote = async () => {
|
||||
if (record.value.id && notes.value) {
|
||||
if (record.value.remark !== notes.value) {
|
||||
await addNoteAPI(record.value.id, notes.value);
|
||||
}
|
||||
showTip3.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSelect = (index) => {
|
||||
selectedIndex.value = index;
|
||||
data.value = groups.value[index];
|
||||
arrows.value = groups.value[index].list.filter((item) => item.x && item.y);
|
||||
data.value = record.value.groups[index];
|
||||
arrows.value = record.value.groups[index].list.filter(
|
||||
(item) => item.x && item.y
|
||||
);
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const pages = getCurrentPages();
|
||||
const currentPage = pages[pages.length - 2];
|
||||
const lastPage = pages[pages.length - 2];
|
||||
uni.navigateBack({
|
||||
delta: currentPage.route === "pages/point-book" ? 1 : 2,
|
||||
delta: lastPage.route === "pages/point-book-edit" ? 2 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -51,10 +77,38 @@ const ringRates = computed(() => {
|
||||
return rates.map((r) => r / arrows.value.length);
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const shareImage = async () => {
|
||||
if (loading.value) return;
|
||||
loading.value = true;
|
||||
await generateShareImage("shareImageCanvas", record.value);
|
||||
await wxShare("shareImageCanvas");
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (options.id) {
|
||||
const result = await getPointBookDetailAPI(options.id || 164);
|
||||
const result = await getPointBookDetailAPI(options.id || 247);
|
||||
record.value = result;
|
||||
const config = uni.getStorageSync("point-book-config");
|
||||
const bowType = config.bowOption.find(
|
||||
(item) => item.id === record.value.bowType
|
||||
);
|
||||
const bowtargetType = config.targetOption.find(
|
||||
(item) => item.id === record.value.targetType
|
||||
);
|
||||
uni.setStorageSync("point-book", {
|
||||
bowType,
|
||||
bowtargetType,
|
||||
distance: result.distance,
|
||||
amountGroup: result.groups,
|
||||
});
|
||||
const arrowData =
|
||||
record.value.groups && record.value.groups[0]
|
||||
? record.value.groups[0]
|
||||
: {};
|
||||
hasPoint.value = (arrowData.list || []).some((arrow) => arrow.x && arrow.y);
|
||||
notes.value = result.remark || "";
|
||||
config.targetOption.some((item) => {
|
||||
if (item.id === result.targetType) {
|
||||
targetId.value = item.id;
|
||||
@@ -62,12 +116,38 @@ onLoad(async (options) => {
|
||||
}
|
||||
});
|
||||
if (result.groups) {
|
||||
groups.value = result.groups;
|
||||
data.value = result.groups[0];
|
||||
arrows.value = result.groups[0].list;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onShareAppMessage(async () => {
|
||||
const imageUrl = await generateShareCard(
|
||||
"shareCardCanvas",
|
||||
record.value.recordDate,
|
||||
data.value.userTotalRing,
|
||||
data.value.totalRing
|
||||
);
|
||||
return {
|
||||
title: "射箭打卡,今日又精进了一些~",
|
||||
path: "/pages/point-book-detail-share?id=" + record.value.id,
|
||||
imageUrl,
|
||||
};
|
||||
});
|
||||
onShareTimeline(async () => {
|
||||
const imageUrl = await generateShareCard(
|
||||
"shareCardCanvas",
|
||||
record.value.recordDate,
|
||||
data.value.userTotalRing,
|
||||
data.value.totalRing
|
||||
);
|
||||
return {
|
||||
title: "射箭打卡,今日又精进了一些~",
|
||||
query: "id=" + record.value.id,
|
||||
imageUrl,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -75,11 +155,11 @@ onLoad(async (options) => {
|
||||
:bgType="2"
|
||||
bgColor="#F5F5F5"
|
||||
:whiteBackArrow="false"
|
||||
title="分析"
|
||||
title=""
|
||||
:onBack="goBack"
|
||||
>
|
||||
<view class="container">
|
||||
<view class="tab-bar">
|
||||
<!-- <view class="tab-bar">
|
||||
<view
|
||||
v-for="(_, index) in groups"
|
||||
:key="index"
|
||||
@@ -94,13 +174,19 @@ onLoad(async (options) => {
|
||||
}"
|
||||
>{{ index === 0 ? "全部" : `第${index}组` }}</text
|
||||
>
|
||||
<!-- <image
|
||||
src="../static/s-triangle.png"
|
||||
mode="widthFix"
|
||||
:style="{ bottom: selectedIndex !== index ? '0' : '-5px' }"
|
||||
/> -->
|
||||
</view>
|
||||
</view>
|
||||
</view> -->
|
||||
<canvas
|
||||
class="share-canvas"
|
||||
canvas-id="shareCardCanvas"
|
||||
style="width: 375px; height: 300px"
|
||||
></canvas>
|
||||
<canvas
|
||||
class="share-canvas"
|
||||
id="shareImageCanvas"
|
||||
type="2d"
|
||||
:style="`width: 375px; height: ${hasPoint ? 800 : 440}px`"
|
||||
></canvas>
|
||||
<view class="detail-data">
|
||||
<view>
|
||||
<view
|
||||
@@ -132,45 +218,60 @@ onLoad(async (options) => {
|
||||
<view>总环数</view>
|
||||
<text>{{ data.userTotalRing }}/{{ data.totalRing }}</text>
|
||||
</view>
|
||||
<button
|
||||
hover-class="none"
|
||||
@click="() => openTip(3)"
|
||||
v-if="user.id === record.user.id"
|
||||
>
|
||||
<image
|
||||
:src="`../static/${notes ? 'has' : 'add'}-note.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text>{{ notes ? "我的备注" : "添加备注" }}</text>
|
||||
</button>
|
||||
</view>
|
||||
<view class="title-bar">
|
||||
<view class="title-bar" v-if="hasPoint">
|
||||
<view />
|
||||
<text>落点分布</text>
|
||||
<button hover-class="none" @click="() => openTip(2)">
|
||||
<!-- <button hover-class="none" @click="() => openTip(2)">
|
||||
<image
|
||||
src="../static/s-question-mark.png"
|
||||
mode="widthFix"
|
||||
class="question-mark"
|
||||
/>
|
||||
</button>
|
||||
</button> -->
|
||||
</view>
|
||||
<view :style="{ transform: 'translateY(-45rpx)' }">
|
||||
<view
|
||||
:style="{ transform: 'translateY(-64rpx) scale(0.9)' }"
|
||||
v-if="hasPoint"
|
||||
>
|
||||
<BowTargetEdit
|
||||
:id="targetId"
|
||||
:src="targetSrc"
|
||||
:arrows="arrows.filter((item) => item.x && item.y)"
|
||||
:editMode="false"
|
||||
/>
|
||||
</view>
|
||||
<view :style="{ transform: 'translateY(-90rpx)' }">
|
||||
<view class="title-bar">
|
||||
<view :style="{ transform: hasPoint ? 'translateY(-100rpx)' : 'none' }">
|
||||
<!-- <view class="title-bar">
|
||||
<view />
|
||||
<text>环值分布</text>
|
||||
</view>
|
||||
</view> -->
|
||||
<view :style="{ padding: '0 30rpx' }">
|
||||
<RingBarChart :data="ringRates" />
|
||||
</view>
|
||||
<view class="title-bar" :style="{ marginTop: '30rpx' }">
|
||||
<!-- <view class="title-bar" :style="{ marginTop: '30rpx' }">
|
||||
<view />
|
||||
<text>{{
|
||||
selectedIndex === 0 ? "每组环数" : `第${selectedIndex}组环数`
|
||||
}}</text>
|
||||
</view>
|
||||
</view> -->
|
||||
<view class="ring-text-groups">
|
||||
<view v-for="(item, index) in groups" :key="index">
|
||||
<text v-if="selectedIndex === 0 && index !== 0">{{
|
||||
`第${index}组`
|
||||
}}</text>
|
||||
<view v-for="(item, index) in record.groups" :key="index">
|
||||
<view v-if="selectedIndex === 0 && index !== 0">
|
||||
<text>{{ index }}:</text>
|
||||
<text>{{ item.userTotalRing }}</text>
|
||||
<text>环</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="
|
||||
(selectedIndex === 0 && index !== 0) ||
|
||||
@@ -186,32 +287,62 @@ onLoad(async (options) => {
|
||||
}"
|
||||
>
|
||||
{{
|
||||
arrow.ring === 0
|
||||
? "X"
|
||||
: arrow.ring === -1
|
||||
? "M"
|
||||
: arrow.ring + "环"
|
||||
arrow.ring === 0 ? "X" : arrow.ring === -1 ? "M" : arrow.ring
|
||||
}}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ marginBottom: '40rpx' }">
|
||||
<SButton :onClick="goBack" :rounded="50">关闭</SButton>
|
||||
<view
|
||||
class="btns"
|
||||
:style="{
|
||||
gridTemplateColumns: `repeat(${
|
||||
user.id === record.user.id ? 2 : 1
|
||||
}, 1fr)`,
|
||||
}"
|
||||
>
|
||||
<button hover-class="none" @click="goBack">关闭</button>
|
||||
<button
|
||||
hover-class="none"
|
||||
@click="shareImage"
|
||||
v-if="user.id === record.user.id"
|
||||
>
|
||||
分享
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<ScreenHint2 :show="showTip || showTip2" :onClose="closeTip">
|
||||
<ScreenHint2 :show="showTip || showTip2 || showTip3" :onClose="closeTip">
|
||||
<view class="tip-content">
|
||||
<block v-if="showTip">
|
||||
<text>落点稳定性说明</text>
|
||||
<text
|
||||
>通过计算每支箭与其他箭的平均距离衡一量射箭的稳定性,数字越小则说明射箭越稳定。该数据只能在用户标记落点的情况下生成。</text
|
||||
>通过计算每支箭与其他箭的平均距离衡量射箭的稳定性,数字越小则说明射箭越稳定。该数据只能在用户标记落点的情况下生成。</text
|
||||
>
|
||||
</block>
|
||||
<block v-if="showTip2">
|
||||
<text>落点分布说明</text>
|
||||
<text>展示用户某次练习中射箭的点位</text>
|
||||
</block>
|
||||
<block v-if="showTip3">
|
||||
<text>备注</text>
|
||||
<textarea
|
||||
v-model="notes"
|
||||
maxlength="300"
|
||||
rows="3"
|
||||
class="notes-input"
|
||||
placeholder="写下本次射箭的补充信息与心得"
|
||||
placeholder-style="color: #ccc;"
|
||||
/>
|
||||
<view>
|
||||
<button
|
||||
hover-class="none"
|
||||
@click="saveNote"
|
||||
:class="notes ? '' : 'button-disabled'"
|
||||
>
|
||||
保存备注
|
||||
</button>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</ScreenHint2>
|
||||
</view>
|
||||
@@ -264,8 +395,10 @@ onLoad(async (options) => {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
column-gap: 3vw;
|
||||
margin: 10rpx 30rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.detail-data > view {
|
||||
.detail-data > view,
|
||||
.detail-data > button {
|
||||
border-radius: 10px;
|
||||
background-color: #fff;
|
||||
margin-bottom: 20rpx;
|
||||
@@ -283,6 +416,18 @@ onLoad(async (options) => {
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
}
|
||||
.detail-data > button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
font-size: 24rpx;
|
||||
color: #333333;
|
||||
}
|
||||
.detail-data > button > image {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
.question-mark {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
@@ -310,10 +455,11 @@ onLoad(async (options) => {
|
||||
}
|
||||
.tip-content {
|
||||
width: 100%;
|
||||
padding: 25px;
|
||||
padding: 50rpx 44rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tip-content > text {
|
||||
width: 100%;
|
||||
@@ -326,11 +472,38 @@ onLoad(async (options) => {
|
||||
margin-top: 20px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.tip-content > view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.tip-content > view > input {
|
||||
width: 80%;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid #eeeeee;
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
}
|
||||
.tip-content > view > button {
|
||||
width: 48%;
|
||||
border-radius: 44rpx;
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
background: #fed847;
|
||||
}
|
||||
.button-disabled {
|
||||
background: linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%) !important;
|
||||
color: #ccc !important;
|
||||
}
|
||||
.ring-text-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20rpx;
|
||||
padding-top: 30rpx;
|
||||
padding-top: 50rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
}
|
||||
@@ -338,22 +511,72 @@ onLoad(async (options) => {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.ring-text-groups > view > text {
|
||||
width: 82rpx;
|
||||
.ring-text-groups > view > view:first-child:nth-last-child(2) {
|
||||
margin-top: 10rpx;
|
||||
width: 115rpx;
|
||||
text-align: center;
|
||||
font-size: 27rpx;
|
||||
justify-content: flex-start;
|
||||
font-size: 20rpx;
|
||||
display: flex;
|
||||
color: #999;
|
||||
}
|
||||
.ring-text-groups > view > view {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, auto);
|
||||
grid-gap: 10rpx;
|
||||
.ring-text-groups > view > view:first-child:nth-last-child(2) > text {
|
||||
line-height: 30rpx;
|
||||
}
|
||||
.ring-text-groups
|
||||
> view
|
||||
> view:first-child:nth-last-child(2)
|
||||
> text:nth-child(2) {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
margin-right: 6rpx;
|
||||
margin-top: -5rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ring-text-groups > view > view:last-child {
|
||||
width: 80%;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 30rpx;
|
||||
margin-left: 20rpx;
|
||||
}
|
||||
.ring-text-groups > view > view > text {
|
||||
width: 1fr;
|
||||
.ring-text-groups > view > view:last-child > text {
|
||||
width: 16.6%;
|
||||
text-align: center;
|
||||
margin-bottom: 10rpx;
|
||||
font-weight: 500;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
.notes-input {
|
||||
width: calc(100% - 40rpx);
|
||||
min-width: calc(100% - 40rpx);
|
||||
margin: 25rpx 0;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
color: #000;
|
||||
padding: 20rpx;
|
||||
}
|
||||
.btns {
|
||||
margin-bottom: 40rpx;
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
column-gap: 20rpx;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
.btns > button {
|
||||
height: 84rpx;
|
||||
line-height: 84rpx;
|
||||
background: linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%), #ffffff;
|
||||
border-radius: 44rpx;
|
||||
border: 2rpx solid #eeeeee;
|
||||
box-sizing: border-box;
|
||||
font-weight: 500;
|
||||
font-size: 30rpx;
|
||||
color: #000000;
|
||||
}
|
||||
.btns > button:nth-child(2) {
|
||||
background: #fed847;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ScreenHint2 from "@/components/ScreenHint2.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
@@ -44,7 +45,7 @@ const onSubmit = async () => {
|
||||
currentGroup.value++;
|
||||
currentArrow.value = 0;
|
||||
} else {
|
||||
const pointBook = uni.getStorageSync("point-book");
|
||||
const pointBook = uni.getStorageSync("last-point-book");
|
||||
const res = await savePointBookAPI(
|
||||
pointBook.bowType.id,
|
||||
pointBook.distance,
|
||||
@@ -54,6 +55,7 @@ const onSubmit = async () => {
|
||||
Object.values(arrowGroups.value)
|
||||
);
|
||||
if (res.record_id) {
|
||||
uni.removeStorageSync("last-point-record");
|
||||
uni.redirectTo({
|
||||
url: `/pages/point-book-detail?id=${res.record_id}`,
|
||||
});
|
||||
@@ -64,18 +66,36 @@ const onClickRing = (ring) => {
|
||||
if (arrowGroups.value[currentGroup.value]) {
|
||||
arrowGroups.value[currentGroup.value][currentArrow.value] = { ring };
|
||||
if (currentArrow.value < amount.value - 1) currentArrow.value++;
|
||||
uni.setStorageSync("last-point-record", arrowGroups.value);
|
||||
}
|
||||
};
|
||||
const deleteArrow = () => {
|
||||
arrowGroups.value[currentGroup.value][currentArrow.value] = {};
|
||||
const arrow = arrowGroups.value[currentGroup.value][currentArrow.value];
|
||||
if (JSON.stringify(arrow) === "{}") {
|
||||
currentArrow.value -= 1;
|
||||
} else {
|
||||
arrowGroups.value[currentGroup.value][currentArrow.value] = {};
|
||||
}
|
||||
uni.$emit("set-edit-arrow", null);
|
||||
uni.setStorageSync("last-point-record", arrowGroups.value);
|
||||
};
|
||||
const onEditDone = (arrow) => {
|
||||
arrowGroups.value[currentGroup.value][currentArrow.value] = arrow;
|
||||
if (currentArrow.value < amount.value - 1) currentArrow.value++;
|
||||
uni.setStorageSync("last-point-record", arrowGroups.value);
|
||||
};
|
||||
const onSelectArrow = (index) => {
|
||||
currentArrow.value = index;
|
||||
const arrow = arrowGroups.value[currentGroup.value][currentArrow.value];
|
||||
if (arrow && arrow.x && arrow.y) {
|
||||
uni.$emit("set-edit-arrow", index);
|
||||
} else {
|
||||
uni.$emit("set-edit-arrow", null);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const pointBook = uni.getStorageSync("point-book");
|
||||
onLoad((options) => {
|
||||
const pointBook = uni.getStorageSync("last-point-book");
|
||||
if (pointBook.bowtargetType) {
|
||||
bowtarget.value = pointBook.bowtargetType;
|
||||
if (bowtarget.value.id > 3) {
|
||||
@@ -92,16 +112,27 @@ onMounted(() => {
|
||||
arrowGroups.value[i] = new Array(amount.value).fill({});
|
||||
}
|
||||
}
|
||||
if (options.withDraft) {
|
||||
const draft = uni.getStorageSync("last-point-record");
|
||||
if (draft) {
|
||||
Object.values(draft).some((arrows, index1) =>
|
||||
arrows.some((arrow, index2) => {
|
||||
currentArrow.value = index2;
|
||||
currentGroup.value = index1 + 1;
|
||||
return JSON.stringify(arrow) === "{}";
|
||||
})
|
||||
);
|
||||
arrowGroups.value = draft;
|
||||
}
|
||||
}
|
||||
// uni.enableAlertBeforeUnload({
|
||||
// message: "现在离开会导致未提交的数据丢失,是否继续?",
|
||||
// });
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container
|
||||
:bgType="2"
|
||||
bgColor="#F5F5F5"
|
||||
:whiteBackArrow="false"
|
||||
:onBack="() => (showTip = true)"
|
||||
>
|
||||
<Container :bgType="2" bgColor="#F5F5F5" :whiteBackArrow="false">
|
||||
<view class="container">
|
||||
<BowTargetEdit
|
||||
:onChange="onEditDone"
|
||||
@@ -124,7 +155,7 @@ onMounted(() => {
|
||||
v-if="arrowGroups[currentGroup]"
|
||||
v-for="(arrow, index) in arrowGroups[currentGroup]"
|
||||
:key="index"
|
||||
@click="currentArrow = index"
|
||||
@click="onSelectArrow(index)"
|
||||
:style="{
|
||||
borderColor: currentArrow === index ? '#FED847' : '#eeeeee',
|
||||
borderWidth: currentArrow === index ? '2px' : '1px',
|
||||
@@ -135,8 +166,8 @@ onMounted(() => {
|
||||
: arrow.ring
|
||||
? arrow.ring + " 环"
|
||||
: ""
|
||||
}}</view
|
||||
>
|
||||
}}
|
||||
</view>
|
||||
</view>
|
||||
<text>推荐在靶纸上落点计分,这样可获得稳定性分析</text>
|
||||
<view class="bow-rings">
|
||||
@@ -166,11 +197,11 @@ onMounted(() => {
|
||||
</view>
|
||||
</ScreenHint2>
|
||||
</view>
|
||||
<view :style="{ marginBottom: '20px' }">
|
||||
<SButton :rounded="50" :onClick="onSubmit">
|
||||
{{ currentGroup === groups ? "记完了,提交看分析" : "下一组" }}
|
||||
<template #bottom>
|
||||
<SButton :rounded="50" :onClick="() => $clickSound(onSubmit)">
|
||||
{{ currentGroup === groups ? "保存并查看分析" : "下一组" }}
|
||||
</SButton>
|
||||
</view>
|
||||
</template>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import EditOption from "@/components/EditOption.vue";
|
||||
import PointRecord from "@/components/PointRecord.vue";
|
||||
import ScrollList from "@/components/ScrollList.vue";
|
||||
import { getPointBookListAPI } from "@/apis";
|
||||
import ScreenHint2 from "@/components/ScreenHint2.vue";
|
||||
import { getPointBookListAPI, removePointRecord } from "@/apis";
|
||||
|
||||
const showTip = ref(false);
|
||||
const bowType = ref({});
|
||||
const distance = ref(0);
|
||||
const bowtargetType = ref({});
|
||||
const showModal = ref(false);
|
||||
const selectorIndex = ref(0);
|
||||
const list = ref([]);
|
||||
const removeId = ref("");
|
||||
const pointDraft = ref(null);
|
||||
|
||||
const onListLoading = async (page) => {
|
||||
const result = await getPointBookListAPI(
|
||||
@@ -34,6 +39,22 @@ const openSelector = (index) => {
|
||||
showModal.value = true;
|
||||
};
|
||||
|
||||
const onRemoveRecord = (item) => {
|
||||
removeId.value = item.id;
|
||||
showTip.value = true;
|
||||
};
|
||||
|
||||
const confirmRemove = async () => {
|
||||
try {
|
||||
showTip.value = false;
|
||||
await removePointRecord(removeId.value);
|
||||
list.value = list.value.filter((it) => it.id !== removeId.value);
|
||||
uni.showToast({ title: "已删除", icon: "none" });
|
||||
} catch (e) {
|
||||
uni.showToast({ title: "删除失败,请重试", icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectOption = (itemIndex, value) => {
|
||||
if (itemIndex === 0) {
|
||||
bowType.value = value.name === bowType.value.name ? {} : value;
|
||||
@@ -45,6 +66,22 @@ const onSelectOption = (itemIndex, value) => {
|
||||
showModal.value = false;
|
||||
onListLoading(1);
|
||||
};
|
||||
|
||||
const onRemoveDraft = () => {
|
||||
pointDraft.value = null;
|
||||
uni.removeStorageSync("last-point-record");
|
||||
};
|
||||
|
||||
const toEditPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/point-book-edit?withDraft=true",
|
||||
});
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
const draft = uni.getStorageSync("last-point-record");
|
||||
pointDraft.value = draft ? uni.getStorageSync("last-point-book") : null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -77,9 +114,53 @@ const onSelectOption = (itemIndex, value) => {
|
||||
</view>
|
||||
<view class="point-records">
|
||||
<ScrollList :onLoading="onListLoading">
|
||||
<view v-for="(item, index) in list" :key="index">
|
||||
<PointRecord :data="item" />
|
||||
</view>
|
||||
<uni-swipe-action>
|
||||
<block v-if="pointDraft">
|
||||
<uni-swipe-action-item>
|
||||
<template v-slot:right>
|
||||
<view class="swipe-right" @click="onRemoveDraft">
|
||||
<image
|
||||
class="swipe-icon"
|
||||
src="../static/delete-white.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<view class="point-draft" v-if="pointDraft" @click="toEditPage">
|
||||
<text>{{ pointDraft.bowType.name }}</text>
|
||||
<text>{{ pointDraft.distance }}米</text>
|
||||
<text>{{ pointDraft.bowtargetType.name }}</text>
|
||||
<view>
|
||||
<image src="../static/draft-icon.png" mode="widthFix" />
|
||||
<text>本地草稿</text>
|
||||
<view>
|
||||
<text>计分待完成</text>
|
||||
<image src="../static/back.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</uni-swipe-action-item>
|
||||
<view :style="{ height: '25rpx' }" />
|
||||
</block>
|
||||
<block v-for="(item, index) in list" :key="item.id">
|
||||
<uni-swipe-action-item>
|
||||
<template v-slot:right>
|
||||
<view class="swipe-right" @click="onRemoveRecord(item)">
|
||||
<image
|
||||
class="swipe-icon"
|
||||
src="../static/delete-white.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<PointRecord :data="item" />
|
||||
</uni-swipe-action-item>
|
||||
<view
|
||||
v-if="index < list.length - 1"
|
||||
:style="{ height: '25rpx' }"
|
||||
/>
|
||||
</block>
|
||||
</uni-swipe-action>
|
||||
<view class="no-data" v-if="list.length === 0">暂无数据</view>
|
||||
</ScrollList>
|
||||
</view>
|
||||
@@ -119,6 +200,15 @@ const onSelectOption = (itemIndex, value) => {
|
||||
/>
|
||||
</view>
|
||||
</SModal>
|
||||
<ScreenHint2 :show="showTip">
|
||||
<view class="tip-content">
|
||||
<text>确认删除该记录吗?</text>
|
||||
<view>
|
||||
<button hover-class="none" @click="showTip = false">取消</button>
|
||||
<button hover-class="none" @click="confirmRemove">确认</button>
|
||||
</view>
|
||||
</view>
|
||||
</ScreenHint2>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
@@ -186,4 +276,97 @@ const onSelectOption = (itemIndex, value) => {
|
||||
color: #999999;
|
||||
font-size: 14px;
|
||||
}
|
||||
.tip-content {
|
||||
width: 100%;
|
||||
padding: 25px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #000;
|
||||
}
|
||||
.tip-content > text {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.tip-content > view {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.tip-content > view > button {
|
||||
width: 48%;
|
||||
background: linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%);
|
||||
border-radius: 22px;
|
||||
border: 1px solid #eeeeee;
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
}
|
||||
.tip-content > view > button:last-child {
|
||||
background: #fed847;
|
||||
}
|
||||
/* 右侧滑动按钮(自定义宽度与图标) */
|
||||
.swipe-right {
|
||||
width: 120rpx; /* 这里可按需调整按钮宽度 */
|
||||
height: 100%;
|
||||
background-color: #ff7c7c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.swipe-icon {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
}
|
||||
.point-draft {
|
||||
height: 200rpx;
|
||||
border-radius: 25rpx;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.point-draft > text {
|
||||
font-weight: 500;
|
||||
font-size: 40rpx;
|
||||
color: #333333;
|
||||
margin: 0 20rpx;
|
||||
}
|
||||
.point-draft > view:last-child {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: #000000b3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.point-draft > view:last-child > image {
|
||||
width: 46rpx;
|
||||
height: 38rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.point-draft > view:last-child > text {
|
||||
font-weight: 500;
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
.point-draft > view:last-child > view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22rpx;
|
||||
color: #ffffff;
|
||||
transform: translateX(8rpx);
|
||||
}
|
||||
.point-draft > view:last-child > view > image {
|
||||
width: 30rpx;
|
||||
height: 30rpx;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
152
src/pages/point-book-rank.vue
Normal file
152
src/pages/point-book-rank.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import Container from "@/components/Container.vue";
|
||||
import PointRankItem from "@/components/PointRankItem.vue";
|
||||
import { getPointBookRankListAPI } from "@/apis";
|
||||
import { capsuleHeight } from "@/util";
|
||||
import { wxShare, debounce } from "@/util";
|
||||
import { sharePointData } from "@/canvas";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const { user } = storeToRefs(useStore());
|
||||
|
||||
const list = ref([]);
|
||||
const mine = ref({
|
||||
averageRing: 0,
|
||||
});
|
||||
|
||||
const shareImage = async () => {
|
||||
if (!mine.value.id) return;
|
||||
await sharePointData("shareCanvas", mine.value);
|
||||
await wxShare("shareCanvas");
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
const result = await getPointBookRankListAPI();
|
||||
mine.value = result.my;
|
||||
list.value = result.list;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="5" bgColor="#F5F5F5" :whiteBackArrow="false">
|
||||
<view class="top-part">
|
||||
<view>
|
||||
<image src="../static/point-champion.png" mode="widthFix" />
|
||||
<image
|
||||
:src="list[0] && list[0].avatar ? list[0].avatar : ''"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<block v-if="list[0]">
|
||||
<text>{{ list[0].name }}占领了封面</text>
|
||||
<text>整整消耗了{{ Math.round(list[0].weekArrow * 1.6) }}大卡!</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="rank-title-bar">
|
||||
<text>排行</text>
|
||||
<text>用户</text>
|
||||
<text>本周箭数</text>
|
||||
<text>消耗</text>
|
||||
</view>
|
||||
<view
|
||||
class="data-list"
|
||||
:style="{ marginBottom: '20rpx' }"
|
||||
v-if="user.id && mine"
|
||||
>
|
||||
<PointRankItem :data="mine" :borderWidth="0" />
|
||||
</view>
|
||||
<view class="data-list">
|
||||
<PointRankItem v-for="item in list" :key="item.id" :data="item" />
|
||||
</view>
|
||||
<view :style="{ height: '30rpx' }"></view>
|
||||
<button
|
||||
hover-class="none"
|
||||
class="share-btn"
|
||||
@click="shareImage"
|
||||
v-if="user.id"
|
||||
>
|
||||
<image src="../static/share-icon.png" mode="widthFix" />
|
||||
</button>
|
||||
<canvas
|
||||
class="share-canvas"
|
||||
id="shareCanvas"
|
||||
type="2d"
|
||||
style="width: 375px; height: 460px"
|
||||
></canvas>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.top-part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
height: 450rpx;
|
||||
}
|
||||
.top-part > view:first-child {
|
||||
width: 310rpx;
|
||||
height: 310rpx;
|
||||
position: relative;
|
||||
}
|
||||
.top-part > view:first-child > image:first-child {
|
||||
width: 100%;
|
||||
}
|
||||
.top-part > view:first-child > image:nth-child(2) {
|
||||
position: absolute;
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 50%;
|
||||
top: calc(50% - 70rpx);
|
||||
left: calc(50% - 70rpx);
|
||||
}
|
||||
.top-part > text {
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
.rank-title-bar {
|
||||
font-size: 24rpx;
|
||||
color: #777777;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: calc(100% - 80rpx);
|
||||
line-height: 80rpx;
|
||||
padding: 0 40rpx;
|
||||
}
|
||||
.rank-title-bar > text:nth-child(1) {
|
||||
width: 60rpx;
|
||||
}
|
||||
.rank-title-bar > text:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
.rank-title-bar > text:nth-child(3) {
|
||||
width: 18%;
|
||||
}
|
||||
.rank-title-bar > text:nth-child(4) {
|
||||
width: 24%;
|
||||
}
|
||||
.data-list {
|
||||
background: $uni-white;
|
||||
border-radius: 25rpx;
|
||||
margin: 0 25rpx;
|
||||
}
|
||||
.share-btn {
|
||||
position: fixed;
|
||||
right: 25rpx;
|
||||
bottom: 25rpx;
|
||||
}
|
||||
.share-btn > image {
|
||||
width: 116rpx;
|
||||
height: 116rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -4,15 +4,15 @@ import { onShow, onShareAppMessage, onShareTimeline } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import PointRecord from "@/components/PointRecord.vue";
|
||||
import RingBarChart from "@/components/RingBarChart.vue";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import ScreenHint2 from "@/components/ScreenHint2.vue";
|
||||
import RewardUs from "@/components/RewardUs.vue";
|
||||
import PointRankItem from "@/components/PointRankItem.vue";
|
||||
|
||||
import {
|
||||
getHomeData,
|
||||
getPointBookConfigAPI,
|
||||
getPointBookListAPI,
|
||||
getPointBookRankListAPI,
|
||||
getPointBookStatisticsAPI,
|
||||
} from "@/apis";
|
||||
|
||||
@@ -26,14 +26,12 @@ const store = useStore();
|
||||
const { updateUser } = store;
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const isIOS = computed(() => {
|
||||
const systemInfo = uni.getDeviceInfo();
|
||||
return systemInfo.osName === "ios";
|
||||
});
|
||||
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||
|
||||
const loadImage = ref(false);
|
||||
const showModal = ref(false);
|
||||
const showTip = ref(false);
|
||||
const showTip2 = ref(false);
|
||||
const data = ref({
|
||||
weeksCheckIn: [],
|
||||
});
|
||||
@@ -42,10 +40,21 @@ const list = ref([]);
|
||||
const bowTargetSrc = ref("");
|
||||
const heatMapImageSrc = ref(""); // 存储热力图图片地址
|
||||
const canvasVisible = ref(false); // 控制canvas显示状态
|
||||
const strength = ref(0);
|
||||
|
||||
const toListPage = () => {
|
||||
const toRecordPage = () => {
|
||||
if (user.value.id) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/point-book-list",
|
||||
});
|
||||
} else {
|
||||
showModal.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const toRankPage = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/point-book-list",
|
||||
url: "/pages/point-book-rank",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -55,19 +64,39 @@ const onSignin = () => {
|
||||
|
||||
const startScoring = () => {
|
||||
if (user.value.id) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/point-book-create",
|
||||
});
|
||||
const draft = uni.getStorageSync("last-point-record");
|
||||
if (draft) {
|
||||
showTip2.value = true;
|
||||
return;
|
||||
}
|
||||
toScorePage();
|
||||
} else {
|
||||
showModal.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const toScorePage = (withDraft) => {
|
||||
showTip2.value = false;
|
||||
if (withDraft) {
|
||||
return uni.navigateTo({
|
||||
url: "/pages/point-book-edit?withDraft=true",
|
||||
});
|
||||
}
|
||||
uni.removeStorageSync("last-point-record");
|
||||
return uni.navigateTo({
|
||||
url: "/pages/point-book-create",
|
||||
});
|
||||
};
|
||||
|
||||
const closeHint = () => {
|
||||
showTip.value = false;
|
||||
showTip2.value = false;
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const result = await getPointBookListAPI(1);
|
||||
list.value = result.slice(0, 3);
|
||||
const result2 = await getPointBookStatisticsAPI();
|
||||
data.value = result2;
|
||||
strength.value = Math.min(10, (5 / 60) * result2.todayTotalArrow);
|
||||
|
||||
const rect = await getElementRect(".heat-map");
|
||||
let hot = 0;
|
||||
@@ -122,6 +151,10 @@ const loadData = async () => {
|
||||
generateHeatmapAsync();
|
||||
};
|
||||
|
||||
const strengthText = computed(() => {
|
||||
return strength.value > 6 ? "重度" : strength.value >= 4 ? "中度" : "轻度";
|
||||
});
|
||||
|
||||
watch(
|
||||
() => user.value.id,
|
||||
(id) => {
|
||||
@@ -130,7 +163,13 @@ watch(
|
||||
);
|
||||
|
||||
onShow(async () => {
|
||||
uni.removeStorageSync("point-book");
|
||||
if (user.value.id) loadData();
|
||||
const result = await getPointBookRankListAPI(1);
|
||||
list.value = result.list.slice(0, 3);
|
||||
if (user.value.id && list.value.every((item) => item.id !== user.value.id)) {
|
||||
list.value = [result.my, ...result.list.slice(0, 3)];
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -244,38 +283,69 @@ onShareTimeline(() => {
|
||||
</view>
|
||||
<view class="statistics">
|
||||
<view>
|
||||
<text>{{ data.todayTotalArrow || "-" }}</text>
|
||||
<text>今日射箭(箭)</text>
|
||||
<view class="statistics-item">
|
||||
<text>{{ data.todayTotalArrow || "-" }}</text>
|
||||
<text>箭</text>
|
||||
<text>今日射箭</text>
|
||||
</view>
|
||||
<view class="statistics-item" :style="{ padding: '20rpx 0' }">
|
||||
<text>{{ Math.round(data.todayTotalArrow * 1.6) || "-" }}</text>
|
||||
<text>卡</text>
|
||||
<text>今日消耗</text>
|
||||
</view>
|
||||
<view class="statistics-item">
|
||||
<text>{{ strength || "-" }}</text>
|
||||
<text v-show="strength" class="strength">{{ strengthText }}</text>
|
||||
<text>运动强度</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{ data.totalArrow || "-" }}</text>
|
||||
<text>累计射箭(箭)</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{ data.totalDay || "-" }}</text>
|
||||
<text>已训练天数(天)</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{ data.averageRing || "-" }}</text>
|
||||
<text>平均环数(箭)</text>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{
|
||||
data.yellowRate !== undefined
|
||||
? Number((data.yellowRate * 100).toFixed(2)) + "%"
|
||||
: "-"
|
||||
}}</text>
|
||||
<text>黄心率</text>
|
||||
</view>
|
||||
<view>
|
||||
<button hover-class="none" @click="startScoring">
|
||||
<image src="../static/start-scoring.png" mode="widthFix" />
|
||||
</button>
|
||||
<view :style="{ paddingBottom: '20rpx' }">
|
||||
<view class="statistics-item">
|
||||
<text>{{ data.totalDay || "-" }}</text>
|
||||
<text>天</text>
|
||||
<text>训练天数</text>
|
||||
</view>
|
||||
<view class="statistics-item">
|
||||
<text>{{ data.totalArrow || "-" }}</text>
|
||||
<text>箭</text>
|
||||
<text>累计射箭</text>
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ marginTop: '20rpx' }">
|
||||
<view class="statistics-item">
|
||||
<text>{{
|
||||
data.yellowRate !== undefined
|
||||
? Number((data.yellowRate * 100).toFixed(2))
|
||||
: "-"
|
||||
}}</text>
|
||||
<text>%</text>
|
||||
<text>黄心率</text>
|
||||
</view>
|
||||
<view class="statistics-item">
|
||||
<text>{{ data.averageRing || "-" }}</text>
|
||||
<text>环</text>
|
||||
<text>平均环数</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<button hover-class="none" @click="$clickSound(toRecordPage)" class="image-btn">
|
||||
<image src="../static/record-btn.png" mode="widthFix" />
|
||||
</button>
|
||||
<button hover-class="none" @click="$clickSound(startScoring)" class="image-btn">
|
||||
<image src="../static/start-scoring.png" mode="widthFix" />
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="title" :style="{ marginBottom: 0 }">
|
||||
<image src="../static/point-book-title1.png" mode="widthFix" />
|
||||
</view>
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-12-31/dfc9dxrpyf4exh4rhd.png"
|
||||
mode="widthFix"
|
||||
class="bowtarget-theme"
|
||||
/>
|
||||
<view class="heat-map">
|
||||
<image
|
||||
:src="bowTargetSrc || '../static/bow-target.png'"
|
||||
@@ -291,7 +361,6 @@ onShareTimeline(() => {
|
||||
</view>
|
||||
<canvas
|
||||
id="heatMapCanvas"
|
||||
canvas-id="heatMapCanvas"
|
||||
type="2d"
|
||||
style="
|
||||
width: 100%;
|
||||
@@ -312,29 +381,52 @@ onShareTimeline(() => {
|
||||
<view class="title" v-if="user.id">
|
||||
<image src="../static/point-book-title2.png" mode="widthFix" />
|
||||
</view>
|
||||
<block v-for="(item, index) in list" :key="index">
|
||||
<PointRecord :data="item" />
|
||||
</block>
|
||||
<view class="top-list">
|
||||
<view class="rank-title-bar">
|
||||
<text>排行</text>
|
||||
<text>用户</text>
|
||||
<text>本周箭数</text>
|
||||
<text>消耗</text>
|
||||
</view>
|
||||
<PointRankItem v-for="item in list" :key="item.id" :data="item" />
|
||||
</view>
|
||||
<view
|
||||
class="see-more"
|
||||
@click="toListPage"
|
||||
@click="toRankPage"
|
||||
v-if="list.length"
|
||||
:style="{ marginBottom: isIOS ? '10rpx' : 0 }"
|
||||
>
|
||||
<text>查看所有记录</text>
|
||||
<text>查看完整榜单</text>
|
||||
<image src="../static/enter-arrow-blue.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<SModal :show="showModal" :onClose="() => (showModal = false)" :noBg="true">
|
||||
<Signin :onClose="() => (showModal = false)" :noBg="true" />
|
||||
</SModal>
|
||||
<ScreenHint2 :show="showTip" :onClose="() => (showTip = false)">
|
||||
<RewardUs :show="showTip" :onClose="() => (showTip = false)" />
|
||||
<Signin
|
||||
:show="showModal"
|
||||
:onClose="() => (showModal = false)"
|
||||
:noBg="true"
|
||||
/>
|
||||
<ScreenHint2 :show="showTip || showTip2" :onClose="closeHint">
|
||||
<RewardUs
|
||||
v-if="showTip"
|
||||
:show="showTip"
|
||||
:onClose="() => (showTip = false)"
|
||||
/>
|
||||
<view class="tip-content" v-if="showTip2">
|
||||
<text>发现未完成的记分,是否继续编辑?</text>
|
||||
<view>
|
||||
<button hover-class="none" @click="toScorePage(false)">
|
||||
重新计分
|
||||
</button>
|
||||
<button hover-class="none" @click="toScorePage(true)">
|
||||
继续编辑
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</ScreenHint2>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: calc(100% - 50rpx);
|
||||
padding: 25rpx;
|
||||
@@ -347,38 +439,67 @@ onShareTimeline(() => {
|
||||
background: #fff;
|
||||
font-size: 22rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 25rpx 0;
|
||||
margin-bottom: 10rpx;
|
||||
justify-content: space-between;
|
||||
padding: 40rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.statistics > view {
|
||||
width: 33.33%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.statistics > view:first-child {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: $uni-border;
|
||||
}
|
||||
.statistics > view:nth-child(-n + 3) {
|
||||
margin-bottom: 25rpx;
|
||||
.statistics > view:first-child > view {
|
||||
width: 210rpx;
|
||||
}
|
||||
.statistics > view:nth-child(2),
|
||||
.statistics > view:nth-child(5) {
|
||||
border-left: 1rpx solid #eeeeee;
|
||||
border-right: 1rpx solid #eeeeee;
|
||||
box-sizing: border-box;
|
||||
.statistics > view:last-child {
|
||||
flex: 1;
|
||||
}
|
||||
.statistics > view > text {
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
color: #333333;
|
||||
.statistics > view:last-child > view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: calc(100% - 20rpx);
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.statistics > view > text:first-child {
|
||||
.statistics > view:last-child > view:first-child {
|
||||
border-bottom: $uni-border;
|
||||
}
|
||||
.statistics-item {
|
||||
width: 180rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
color: $uni-text-color;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.statistics-item > text:first-child {
|
||||
font-weight: 500;
|
||||
font-size: 40rpx;
|
||||
margin-bottom: 10rpx;
|
||||
margin-right: 10rpx;
|
||||
}
|
||||
.statistics > view:last-child > button > image {
|
||||
width: 164rpx;
|
||||
.statistics-item > text:nth-child(2) {
|
||||
transform: translateY(16rpx);
|
||||
}
|
||||
.statistics-item > text:nth-child(3) {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
.image-btn {
|
||||
width: 170rpx;
|
||||
height: 74rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: unset;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
.image-btn > image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.daily-signin {
|
||||
display: grid;
|
||||
@@ -445,6 +566,8 @@ onShareTimeline(() => {
|
||||
width: calc(100vw - 70rpx);
|
||||
height: calc(100vw - 70rpx);
|
||||
transform: scale(0.9);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.heat-map > image {
|
||||
width: 100%;
|
||||
@@ -479,4 +602,78 @@ onShareTimeline(() => {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tip-content {
|
||||
width: 100%;
|
||||
padding: 25px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: #000;
|
||||
}
|
||||
.tip-content > text {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.tip-content > view {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.tip-content > view > button {
|
||||
width: 48%;
|
||||
background: linear-gradient(180deg, #fbfbfb 0%, #f5f5f5 100%);
|
||||
border-radius: 22px;
|
||||
border: 1px solid #eeeeee;
|
||||
padding: 12px 0;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
}
|
||||
.tip-content > view > button:last-child {
|
||||
background: #fed847;
|
||||
}
|
||||
.bowtarget-theme {
|
||||
width: 100vw;
|
||||
margin-left: -25rpx;
|
||||
margin-bottom: -30vw;
|
||||
}
|
||||
.top-list {
|
||||
background: $uni-white;
|
||||
border-radius: 25rpx;
|
||||
border: 2rpx solid #fed848;
|
||||
overflow: hidden;
|
||||
}
|
||||
.rank-title-bar {
|
||||
background: $uni-white;
|
||||
font-size: 24rpx;
|
||||
color: #777777;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
width: calc(100% - 40rpx);
|
||||
line-height: 80rpx;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
.rank-title-bar > text:nth-child(1) {
|
||||
width: 55rpx;
|
||||
}
|
||||
.rank-title-bar > text:nth-child(2) {
|
||||
flex: 1;
|
||||
}
|
||||
.rank-title-bar > text:nth-child(3) {
|
||||
width: 16%;
|
||||
}
|
||||
.rank-title-bar > text:nth-child(4) {
|
||||
width: 25%;
|
||||
}
|
||||
.strength {
|
||||
font-size: 22rpx;
|
||||
color: #777777;
|
||||
border-radius: 8rpx;
|
||||
border: 1rpx solid #777777;
|
||||
height: 20rpx;
|
||||
padding: 8rpx;
|
||||
line-height: 20rpx;
|
||||
transform: translateY(10rpx) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ShootProgress from "@/components/ShootProgress.vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
@@ -10,92 +11,127 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import BowPower from "@/components/BowPower.vue";
|
||||
import TestDistance from "@/components/TestDistance.vue";
|
||||
import BubbleTip from "@/components/BubbleTip.vue";
|
||||
import { createPractiseAPI } from "@/apis";
|
||||
import { generateCanvasImage } from "@/util";
|
||||
import { MESSAGETYPES, roundsName } from "@/constants";
|
||||
import audioManager from "@/audioManager";
|
||||
|
||||
import {
|
||||
createPractiseAPI,
|
||||
startPractiseAPI,
|
||||
endPractiseAPI,
|
||||
getPractiseAPI,
|
||||
} from "@/apis";
|
||||
import { sharePractiseData } from "@/canvas";
|
||||
import { wxShare, debounce } from "@/util";
|
||||
import { MESSAGETYPESV2, roundsName } from "@/constants";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const start = ref(false);
|
||||
const scores = ref([]);
|
||||
const isSvip = ref(false);
|
||||
const total = 12;
|
||||
const currentRound = ref(0);
|
||||
/** 当前练习中连续 X 环计数,用于触发 tententen 音效 */
|
||||
const xRingStreak = ref(0);
|
||||
const practiseResult = ref({});
|
||||
const power = ref(0);
|
||||
const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const tips = ref("");
|
||||
const targetType = ref(1);
|
||||
|
||||
onLoad((options) => {
|
||||
if (options.target) {
|
||||
targetType.value = Number(options.target);
|
||||
}
|
||||
});
|
||||
|
||||
const onReady = async () => {
|
||||
const result = await createPractiseAPI(total);
|
||||
if (result) practiseId.value = result.id;
|
||||
currentRound.value = 0;
|
||||
await startPractiseAPI();
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
xRingStreak.value = 0; // 新一局开始,重置 X 环连续计数
|
||||
start.value = true;
|
||||
setTimeout(() => {
|
||||
uni.$emit("play-sound", "请开始射击");
|
||||
}, 300);
|
||||
audioManager.play("练习开始");
|
||||
};
|
||||
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
if (scores.value.length < total) {
|
||||
scores.value.push(msg.target);
|
||||
currentRound.value += 1;
|
||||
if (currentRound.value === 4) {
|
||||
currentRound.value = 1;
|
||||
}
|
||||
if (practiseId && scores.value.length === total / 2) {
|
||||
showGuide.value = true;
|
||||
setTimeout(() => {
|
||||
showGuide.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
power.value = msg.target.battery;
|
||||
const onOver = async () => {
|
||||
practiseResult.value = await getPractiseAPI(practiseId.value);
|
||||
start.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测连续 X 环是否达到 3 箭,达到则播放 tententen 音效
|
||||
* @param {boolean} isXRing - 本次射击是否为 X 环
|
||||
*/
|
||||
function checkAndPlayTententen(isXRing) {
|
||||
if (isXRing) {
|
||||
xRingStreak.value += 1;
|
||||
// 连续 3 箭均为 X 环,在环数播报入队后追加 tententen,避免播放顺序颠倒
|
||||
if (xRingStreak.value >= 3) {
|
||||
xRingStreak.value = 0;
|
||||
nextTick(() => audioManager.play("tententen", false));
|
||||
}
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMePracticeID) {
|
||||
if (practiseId.value && practiseId.value === msg.practice.id) {
|
||||
setTimeout(() => {
|
||||
start.value = false;
|
||||
practiseResult.value = {
|
||||
...msg.practice,
|
||||
arrows: JSON.parse(msg.practice.arrows),
|
||||
lvl: msg.lvl,
|
||||
};
|
||||
generateCanvasImage(
|
||||
"shareCanvas",
|
||||
2,
|
||||
user.value,
|
||||
practiseResult.value
|
||||
);
|
||||
}, 1500);
|
||||
}
|
||||
} else {
|
||||
// 非 X 环则重置连续计数
|
||||
xRingStreak.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
const prevLen = scores.value.length;
|
||||
isSvip.value = msg.sVip === true;
|
||||
scores.value = msg.details;
|
||||
// 有新箭时取最后一箭判断是否 X 环并检测连续计数
|
||||
if (scores.value.length > prevLen) {
|
||||
const latestArrow = scores.value[scores.value.length - 1];
|
||||
checkAndPlayTententen(!!(latestArrow?.ringX && latestArrow?.ring));
|
||||
}
|
||||
});
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
// setTimeout(onOver, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
async function onComplete() {
|
||||
if (
|
||||
practiseResult.value.arrows &&
|
||||
practiseResult.value.arrows.length === total
|
||||
) {
|
||||
const validArrows = (practiseResult.value.details || []).filter(
|
||||
(a) => a.x !== -30 && a.y !== -30
|
||||
);
|
||||
if (validArrows.length === total) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
practiseId.value = "";
|
||||
practiseResult.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
currentRound.value = 0;
|
||||
isSvip.value = false;
|
||||
xRingStreak.value = 0; // 重新开始练习,重置 X 环连续计数
|
||||
const result = await createPractiseAPI(total, 120);
|
||||
if (result) practiseId.value = result.id;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const onClickShare = debounce(async () => {
|
||||
await sharePractiseData("shareCanvas", 2, user.value, practiseResult.value);
|
||||
await wxShare("shareCanvas");
|
||||
});
|
||||
|
||||
function onAudioEnded(s) {
|
||||
if (s.indexOf("比赛结束") >= 0) {
|
||||
onOver()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// audioManager.play("第一轮");
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: true,
|
||||
});
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("share-image", onClickShare);
|
||||
uni.$on("audioEnded", onAudioEnded);
|
||||
const result = await createPractiseAPI(total, 120, targetType.value);
|
||||
if (result) practiseId.value = result.id;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -103,14 +139,22 @@ onBeforeUnmount(() => {
|
||||
keepScreenOn: false,
|
||||
});
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("share-image", onClickShare);
|
||||
uni.$off("audioEnded", onAudioEnded);
|
||||
audioManager.stopAll();
|
||||
endPractiseAPI();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="1" title="个人单组练习">
|
||||
<Container
|
||||
:bgType="1"
|
||||
title="个人单组练习"
|
||||
:showBottom="!start && !scores.length"
|
||||
>
|
||||
<view>
|
||||
<TestDistance v-if="!practiseId" />
|
||||
<block v-if="practiseId">
|
||||
<TestDistance v-if="!start && !practiseResult.id" />
|
||||
<block v-else>
|
||||
<ShootProgress
|
||||
:tips="`${
|
||||
!start || scores.length === 12
|
||||
@@ -120,7 +164,7 @@ onBeforeUnmount(() => {
|
||||
}轮`
|
||||
}`"
|
||||
:start="start"
|
||||
:total="120"
|
||||
:onStop="onOver"
|
||||
/>
|
||||
<view class="user-row">
|
||||
<Avatar :src="user.avatar" :size="35" />
|
||||
@@ -128,30 +172,35 @@ onBeforeUnmount(() => {
|
||||
<text>还有两场,坚持</text>
|
||||
<text>就是胜利!💪</text>
|
||||
</BubbleTip>
|
||||
<BowPower :power="power" />
|
||||
<BowPower />
|
||||
</view>
|
||||
<BowTarget
|
||||
:totalRound="start ? total / 4 : 0"
|
||||
:currentRound="currentRound"
|
||||
:currentRound="scores.length % 3"
|
||||
:scores="scores"
|
||||
:isSvip="isSvip"
|
||||
/>
|
||||
<ScorePanel2 :scores="scores.map((s) => s.ring)" />
|
||||
<ScorePanel2 :arrows="scores" />
|
||||
<ScoreResult
|
||||
v-if="practiseResult.arrows"
|
||||
v-if="practiseResult.details"
|
||||
:rowCount="6"
|
||||
:total="total"
|
||||
:onClose="onComplete"
|
||||
:result="practiseResult"
|
||||
:tipSrc="`../static/${
|
||||
practiseResult.arrows.length < total ? 'un' : ''
|
||||
practiseResult.details.filter(
|
||||
(arrow) => arrow.x !== -30 && arrow.y !== -30
|
||||
).length < total
|
||||
? 'un'
|
||||
: ''
|
||||
}finish-tip.png`"
|
||||
/>
|
||||
<canvas class="share-canvas" canvas-id="shareCanvas"></canvas>
|
||||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||||
</block>
|
||||
</view>
|
||||
<view :style="{ marginBottom: '20px' }">
|
||||
<SButton v-if="!start" :onClick="onReady">准备好了,直接开始</SButton>
|
||||
</view>
|
||||
<template #bottom>
|
||||
<SButton :onClick="onReady">准备好了,直接开始</SButton>
|
||||
</template>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import Container from "@/components/Container.vue";
|
||||
import ShootProgress from "@/components/ShootProgress.vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
@@ -10,85 +10,135 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import BowPower from "@/components/BowPower.vue";
|
||||
import TestDistance from "@/components/TestDistance.vue";
|
||||
import BubbleTip from "@/components/BubbleTip.vue";
|
||||
import { createPractiseAPI } from "@/apis";
|
||||
import { generateCanvasImage } from "@/util";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import audioManager from "@/audioManager";
|
||||
|
||||
import {
|
||||
createPractiseAPI,
|
||||
startPractiseAPI,
|
||||
endPractiseAPI,
|
||||
getPractiseAPI,
|
||||
} from "@/apis";
|
||||
import { sharePractiseData } from "@/canvas";
|
||||
import { wxShare, debounce } from "@/util";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import {onLoad} from "@dcloudio/uni-app";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const start = ref(false);
|
||||
const scores = ref([]);
|
||||
const isSvip = ref(false);
|
||||
const total = 36;
|
||||
/** 当前练习中连续 X 环计数,用于触发 tententen 音效 */
|
||||
const xRingStreak = ref(0);
|
||||
const practiseResult = ref({});
|
||||
const power = ref(0);
|
||||
const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const targetType = ref(1);
|
||||
|
||||
onLoad((options) => {
|
||||
if (options.target) {
|
||||
targetType.value = Number(options.target);
|
||||
}
|
||||
});
|
||||
|
||||
const onReady = async () => {
|
||||
const result = await createPractiseAPI(total);
|
||||
if (result) practiseId.value = result.id;
|
||||
await startPractiseAPI();
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
xRingStreak.value = 0; // 新一局开始,重置 X 环连续计数
|
||||
start.value = true;
|
||||
setTimeout(() => {
|
||||
uni.$emit("play-sound", "请开始射击");
|
||||
}, 300);
|
||||
audioManager.play("练习开始");
|
||||
};
|
||||
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
if (scores.value.length < total) {
|
||||
scores.value.push(msg.target);
|
||||
if (practiseId && scores.value.length === total / 2) {
|
||||
showGuide.value = true;
|
||||
setTimeout(() => {
|
||||
showGuide.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
power.value = msg.target.battery;
|
||||
const onOver = async () => {
|
||||
practiseResult.value = await getPractiseAPI(practiseId.value);
|
||||
start.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* 检测连续 X 环是否达到 3 箭,达到则播放 tententen 音效
|
||||
* @param {boolean} isXRing - 本次射击是否为 X 环
|
||||
*/
|
||||
function checkAndPlayTententen(isXRing) {
|
||||
if (isXRing) {
|
||||
xRingStreak.value += 1;
|
||||
// 连续 3 箭均为 X 环,在环数播报入队后追加 tententen,避免播放顺序颠倒
|
||||
if (xRingStreak.value >= 3) {
|
||||
xRingStreak.value = 0;
|
||||
nextTick(() => audioManager.play("tententen", false));
|
||||
}
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMePracticeID) {
|
||||
if (practiseId.value && practiseId.value === msg.practice.id) {
|
||||
setTimeout(() => {
|
||||
start.value = false;
|
||||
practiseResult.value = {
|
||||
...msg.practice,
|
||||
arrows: JSON.parse(msg.practice.arrows),
|
||||
lvl: msg.lvl,
|
||||
};
|
||||
generateCanvasImage(
|
||||
"shareCanvas",
|
||||
3,
|
||||
user.value,
|
||||
practiseResult.value
|
||||
);
|
||||
}, 1500);
|
||||
}
|
||||
} else {
|
||||
// 非 X 环则重置连续计数
|
||||
xRingStreak.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
const prevLen = scores.value.length;
|
||||
isSvip.value = msg.sVip === true;
|
||||
scores.value = msg.details;
|
||||
// 有新箭时取最后一箭判断是否 X 环并检测连续计数
|
||||
if (scores.value.length > prevLen) {
|
||||
const latestArrow = scores.value[scores.value.length - 1];
|
||||
checkAndPlayTententen(!!(latestArrow?.ringX && latestArrow?.ring));
|
||||
}
|
||||
});
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
setTimeout(onOver, 1500);
|
||||
}
|
||||
// messages.forEach((msg) => {
|
||||
// if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
// if (scores.value.length < total) {
|
||||
// scores.value.push(msg.target);
|
||||
// if (practiseId && scores.value.length === total / 2) {
|
||||
// showGuide.value = true;
|
||||
// setTimeout(() => {
|
||||
// showGuide.value = false;
|
||||
// }, 3000);
|
||||
// }
|
||||
// if (scores.value.length === total) {
|
||||
// setTimeout(onOver, 1500);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
async function onComplete() {
|
||||
if (
|
||||
practiseResult.value.arrows &&
|
||||
practiseResult.value.arrows.length === total
|
||||
) {
|
||||
const validArrows = (practiseResult.value.details || []).filter(
|
||||
(a) => a.x !== -30 && a.y !== -30
|
||||
);
|
||||
if (validArrows.length === total) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
practiseId.value = "";
|
||||
practiseResult.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
xRingStreak.value = 0; // 重新开始练习,重置 X 环连续计数
|
||||
const result = await createPractiseAPI(total, 3600);
|
||||
if (result) practiseId.value = result.id;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const onClickShare = debounce(async () => {
|
||||
await sharePractiseData("shareCanvas", 3, user.value, practiseResult.value);
|
||||
await wxShare("shareCanvas");
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: true,
|
||||
});
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("share-image", onClickShare);
|
||||
const result = await createPractiseAPI(total, 3600, targetType.value);
|
||||
if (result) practiseId.value = result.id;
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -96,18 +146,26 @@ onBeforeUnmount(() => {
|
||||
keepScreenOn: false,
|
||||
});
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("share-image", onClickShare);
|
||||
audioManager.stopAll();
|
||||
endPractiseAPI();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="1" title="日常耐力挑战">
|
||||
<Container
|
||||
:bgType="1"
|
||||
title="日常耐力挑战"
|
||||
:showBottom="!start && !scores.length"
|
||||
>
|
||||
<view>
|
||||
<TestDistance v-if="!practiseId" />
|
||||
<block v-if="practiseId">
|
||||
<TestDistance v-if="!start && !practiseResult.id" />
|
||||
<block v-else>
|
||||
<ShootProgress
|
||||
:start="start"
|
||||
:tips="`请连续射${total}支箭`"
|
||||
:total="120"
|
||||
:start="start"
|
||||
:total="3600"
|
||||
:onStop="onOver"
|
||||
/>
|
||||
<view class="user-row">
|
||||
<Avatar :src="user.avatar" :size="35" />
|
||||
@@ -115,37 +173,42 @@ onBeforeUnmount(() => {
|
||||
<text>完成过半,胜利</text>
|
||||
<text>在望!💪</text>
|
||||
</BubbleTip>
|
||||
<BowPower :power="power" />
|
||||
<BowPower />
|
||||
</view>
|
||||
<BowTarget
|
||||
:currentRound="scores.length"
|
||||
:totalRound="start ? total : 0"
|
||||
:scores="scores"
|
||||
:isSvip="isSvip"
|
||||
/>
|
||||
<ScorePanel
|
||||
v-if="start"
|
||||
:scores="scores.map((s) => s.ring)"
|
||||
:arrows="scores"
|
||||
:total="total"
|
||||
:rowCount="total / 4"
|
||||
:margin="1.5"
|
||||
:font-size="20"
|
||||
/>
|
||||
<ScoreResult
|
||||
v-if="practiseResult.arrows"
|
||||
v-if="practiseResult.details"
|
||||
:total="total"
|
||||
:rowCount="9"
|
||||
:onClose="onComplete"
|
||||
:result="practiseResult"
|
||||
:tipSrc="`../static/${
|
||||
practiseResult.arrows.length < total ? '2un' : ''
|
||||
practiseResult.details.filter(
|
||||
(arrow) => arrow.x !== -30 && arrow.y !== -30
|
||||
).length < total
|
||||
? '2un'
|
||||
: ''
|
||||
}finish-tip.png`"
|
||||
/>
|
||||
<canvas class="share-canvas" canvas-id="shareCanvas"></canvas>
|
||||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||||
</block>
|
||||
</view>
|
||||
<view :style="{ marginBottom: '20px' }">
|
||||
<SButton v-if="!start" :onClick="onReady">准备好了,直接开始</SButton>
|
||||
</view>
|
||||
<template #bottom>
|
||||
<SButton :onClick="onReady">准备好了,直接开始</SButton>
|
||||
</template>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import Guide from "@/components/Guide.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import TargetPicker from "@/components/TargetPicker.vue";
|
||||
import { getPractiseDataAPI } from "@/apis";
|
||||
import { canEenter } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
const { user, device, online } = storeToRefs(store);
|
||||
const data = ref({});
|
||||
const showTargetPicker = ref(false);
|
||||
const pendingPractiseType = ref("");
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||
|
||||
const toPractiseOne = () => {
|
||||
uni.navigateTo({
|
||||
url: "/pages/practise-one",
|
||||
});
|
||||
const goPractise = async (type) => {
|
||||
if (!canEenter(user.value, device.value, online.value)) return;
|
||||
pendingPractiseType.value = type;
|
||||
showTargetPicker.value = true;
|
||||
};
|
||||
|
||||
const toPractiseTwo = () => {
|
||||
const handleTargetConfirm = (target) => {
|
||||
showTargetPicker.value = false;
|
||||
const type = pendingPractiseType.value;
|
||||
uni.navigateTo({
|
||||
url: "/pages/practise-two",
|
||||
url: `/pages/practise-${type}?target=${target}`,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -32,20 +40,29 @@ onShow(async () => {
|
||||
|
||||
<template>
|
||||
<Container title="个人练习">
|
||||
<view :style="{ width: '100%' }">
|
||||
<view :style="{ width: '100%', height: '100%' }">
|
||||
<Guide>
|
||||
<text :style="{ color: '#fed847' }"
|
||||
>师傅领进门,修行靠自身,赶紧练起来吧。</text
|
||||
>
|
||||
<text :style="{ fontSize: '12px' }"
|
||||
>坚持练习就能你快速升级,早日加入全国排位赛!</text
|
||||
>
|
||||
<view class="guide-tips">
|
||||
<text>师傅领进门,修行靠自身,赶紧练起来吧。</text>
|
||||
<text>坚持练习就能你快速升级,早日加入全国排位赛!</text>
|
||||
</view>
|
||||
</Guide>
|
||||
<view class="practise-data">
|
||||
<view>
|
||||
<view>
|
||||
<Avatar :rankLvl="user.rankLvl" :src="user.avatar" :size="30" />
|
||||
<text class="truncate">{{ user.nickName }}</text>
|
||||
<view
|
||||
:class="[
|
||||
'member-nickname',
|
||||
isVip ? 'member-nickname--vip' : '',
|
||||
isSVip ? 'member-nickname--svip' : '',
|
||||
]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||
<text v-if="isSVip" class="member-nickname__shine">{{
|
||||
user.nickName
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<text>已练习打卡</text>
|
||||
@@ -78,14 +95,14 @@ onShow(async () => {
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="practise-btn" @click="toPractiseOne">
|
||||
<view class="practise-btn" @click="() => goPractise('one')">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-07-12/db9x668e2vdtqh0otq.png"
|
||||
class="practise1"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<view class="practise-btn" @click="toPractiseTwo">
|
||||
<view class="practise-btn" @click="() => goPractise('two')">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-07-12/db9x668eehkvyicc08.png"
|
||||
class="practise2"
|
||||
@@ -93,6 +110,11 @@ onShow(async () => {
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<TargetPicker
|
||||
:show="showTargetPicker"
|
||||
:onClose="() => (showTargetPicker = false)"
|
||||
:onConfirm="handleTargetConfirm"
|
||||
/>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
@@ -123,12 +145,15 @@ onShow(async () => {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.practise-data > view:first-child > view:first-child > text {
|
||||
.practise-data > view:first-child > view:first-child .member-nickname {
|
||||
color: #fff;
|
||||
margin-left: 10px;
|
||||
font-size: 16px;
|
||||
width: 120px;
|
||||
}
|
||||
.practise-data > view:first-child > view:first-child .member-nickname__text,
|
||||
.practise-data > view:first-child > view:first-child .member-nickname__shine {
|
||||
font-size: 16px;
|
||||
}
|
||||
.practise-data > view:first-child > view:last-child > text:nth-child(2) {
|
||||
color: #f7d247;
|
||||
margin: 0 3px;
|
||||
|
||||
@@ -13,7 +13,7 @@ import Container from "@/components/Container.vue";
|
||||
<view class="section">
|
||||
<view class="title">一、段位体系概述</view>
|
||||
<view class="text">
|
||||
我们的段位体系分为多个等级,从低到高依次为:铜牌青铜、移动白银、荣耀黄金、永恒钻石、璀璨王者、非凡王者、无双王者、至尊王者、荣耀王者和传奇王者。每个大段位下又分为若干小段位,玩家需要通过积累积分来提升段位。
|
||||
我们的段位体系分为多个等级,从低到高依次为:倔强青铜、秩序白银、黄金王者、永恒钻石、最强王者、非凡王者、无双王者、绝世王者、至圣王者、荣耀王者和传奇王者。每个大段位下又分为若干小段位,玩家需要通过积累积分来提升段位。
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -55,77 +55,77 @@ import Container from "@/components/Container.vue";
|
||||
<view class="section">
|
||||
<view class="title">三、(表格)</view>
|
||||
<view class="rank-table">
|
||||
<view class="table-header">
|
||||
<view>大段位</view>
|
||||
<view>小段位</view>
|
||||
<view>积分(100积分=1星)</view>
|
||||
<view class="table-row">
|
||||
<text>大段位</text>
|
||||
<text>小段位</text>
|
||||
<text>积分(100积分=1星)</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<view>铜牌青铜</view>
|
||||
<text>倔强青铜</text>
|
||||
<view>
|
||||
<view>青铜1*</view>
|
||||
<view>青铜2*</view>
|
||||
<view>青铜3*</view>
|
||||
<text>青铜1*</text>
|
||||
<text>青铜2*</text>
|
||||
<text>青铜3*</text>
|
||||
</view>
|
||||
<view>每个小段位需要累计3星才能晋升到下一个段位,共9星。</view>
|
||||
<text>每个小段位需要满 3星才能晋升到下一个段位,共9颗星。</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<view>移动白银</view>
|
||||
<text>秩序白银</text>
|
||||
<view>
|
||||
<view>白铜1*</view>
|
||||
<view>白铜2*</view>
|
||||
<view>白铜3*</view>
|
||||
<text>白铜1*</text>
|
||||
<text>白铜2*</text>
|
||||
<text>白铜3*</text>
|
||||
</view>
|
||||
<view>每个小段位需要累计3星才能晋升到下一个段位,共9星。</view>
|
||||
<text>每个小段位需要满 3颗星才能晋升到下一个段位,共9颗星。</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<view>黄金王者</view>
|
||||
<text>黄金王者</text>
|
||||
<view>
|
||||
<view>黄金1*</view>
|
||||
<view>黄金2*</view>
|
||||
<view>黄金3*</view>
|
||||
<view>黄金4*</view>
|
||||
<text>黄金1*</text>
|
||||
<text>黄金2*</text>
|
||||
<text>黄金3*</text>
|
||||
<text>黄金4*</text>
|
||||
</view>
|
||||
<view>每个小段位需要累计4星才能晋升到下一个段位,共15星。</view>
|
||||
<text>每个小段位需要满4颗星才能晋升到下一个段位,共16颗星。</text>
|
||||
</view>
|
||||
<view class="table-row">
|
||||
<view>永恒钻石</view>
|
||||
<text>永恒钻石</text>
|
||||
<view>
|
||||
<view>钻石1*</view>
|
||||
<view>钻石2*</view>
|
||||
<view>钻石3*</view>
|
||||
<view>钻石4*</view>
|
||||
<view>钻石5*</view>
|
||||
<text>钻石1*</text>
|
||||
<text>钻石2*</text>
|
||||
<text>钻石3*</text>
|
||||
<text>钻石4*</text>
|
||||
<text>钻石5*</text>
|
||||
</view>
|
||||
<view>每个小段位需要累计5星才能晋升到下一个段位,共25星。</view>
|
||||
<text>每个小段位需要满5颗星才能晋升到下一个段位,共25颗星。</text>
|
||||
</view>
|
||||
<view class="table-row2">
|
||||
<view>最强王者</view>
|
||||
<view>0-9</view>
|
||||
<text>最强王者</text>
|
||||
<text>0-9</text>
|
||||
</view>
|
||||
<view class="table-row2">
|
||||
<view>非凡王者</view>
|
||||
<view>0-9</view>
|
||||
<text>非凡王者</text>
|
||||
<text>10-19</text>
|
||||
</view>
|
||||
<view class="table-row2">
|
||||
<view>无双王者</view>
|
||||
<view>10-19</view>
|
||||
<text>无双王者</text>
|
||||
<text>20-29</text>
|
||||
</view>
|
||||
<view class="table-row2">
|
||||
<view>至尊王者</view>
|
||||
<view>20-29</view>
|
||||
<text>绝世王者</text>
|
||||
<text>30-39</text>
|
||||
</view>
|
||||
<view class="table-row2">
|
||||
<view>荣耀王者</view>
|
||||
<view>30-39</view>
|
||||
<text>至圣王者</text>
|
||||
<text>40-49</text>
|
||||
</view>
|
||||
<view class="table-row2">
|
||||
<view>璀璨王者</view>
|
||||
<view>40-49</view>
|
||||
<text>荣耀王者</text>
|
||||
<text>50-99</text>
|
||||
</view>
|
||||
<view class="table-row2">
|
||||
<view>传奇王者</view>
|
||||
<view>100+</view>
|
||||
<text>传奇王者</text>
|
||||
<text>100+</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -134,7 +134,7 @@ import Container from "@/components/Container.vue";
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
<style scoped lang="scss">
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -195,71 +195,47 @@ import Container from "@/components/Container.vue";
|
||||
}
|
||||
|
||||
.rank-table {
|
||||
border: 1px solid #e4e4e4;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
width: calc(100vw - 20px);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
.rank-table > view {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e4e4e4;
|
||||
}
|
||||
|
||||
.table-header > view {
|
||||
padding: 5px 10px;
|
||||
width: 20%;
|
||||
.rank-table > view > text:last-child {
|
||||
margin-left: -1rpx;
|
||||
}
|
||||
.table-header > view:last-child {
|
||||
padding: 5px 10px;
|
||||
width: 60%;
|
||||
|
||||
.rank-table text {
|
||||
padding: 10rpx 20rpx;
|
||||
border: $uni-border;
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
margin-top: -1rpx;
|
||||
}
|
||||
.table-header > view:nth-child(2) {
|
||||
border-left: 1px solid #e4e4e4;
|
||||
border-right: 1px solid #e4e4e4;
|
||||
|
||||
.table-row text {
|
||||
width: 25%;
|
||||
}
|
||||
.table-row {
|
||||
|
||||
.table-row > view {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
border-bottom: 1px solid #e4e4e4;
|
||||
flex-direction: column;
|
||||
width: 25%;
|
||||
}
|
||||
.table-row > view:first-child,
|
||||
.table-row > view:last-child,
|
||||
.table-row > view:nth-child(2) > view {
|
||||
padding: 5px 10px;
|
||||
|
||||
.table-row > view > text {
|
||||
width: 100%;
|
||||
}
|
||||
.table-row > view:nth-child(2) {
|
||||
border-left: 1px solid #e4e4e4;
|
||||
border-right: 1px solid #e4e4e4;
|
||||
|
||||
.table-row > text:nth-child(3) {
|
||||
width: 50%;
|
||||
}
|
||||
.table-row > view:nth-child(2) > view {
|
||||
border-bottom: 1px solid #e4e4e4;
|
||||
}
|
||||
.table-row > view:nth-child(2) > view:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.table-row > view:first-child {
|
||||
width: 20%;
|
||||
}
|
||||
.table-row > view:nth-child(2) {
|
||||
width: 26.5%;
|
||||
}
|
||||
.table-row > view:last-child {
|
||||
width: 60%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
line-height: 2;
|
||||
}
|
||||
.table-row2 {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e4e4e4;
|
||||
}
|
||||
.table-row2 > view {
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.table-row2 > view:first-child {
|
||||
border-right: 1px solid #e4e4e4;
|
||||
width: 38.8%;
|
||||
|
||||
.table-row2 > text {
|
||||
width: 50%;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,60 +1,344 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { computed, nextTick, onMounted, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import {
|
||||
getMvpRankList,
|
||||
getMyMvpRank,
|
||||
getMyScoreRank,
|
||||
getMyTenRingRank,
|
||||
getScoreRankList,
|
||||
getTenRingRankList,
|
||||
} from "@/apis";
|
||||
import { capsuleHeight } from "@/util";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
const store = useStore();
|
||||
const { user, rankData } = storeToRefs(store);
|
||||
const { user } = storeToRefs(store);
|
||||
const { getLvlName } = store;
|
||||
|
||||
const capsuleHeight = ref(0);
|
||||
const selectedIndex = ref(0);
|
||||
const currentList = ref([]);
|
||||
const myData = ref({});
|
||||
const addBg = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
const menuBtnInfo = uni.getMenuButtonBoundingClientRect();
|
||||
capsuleHeight.value = menuBtnInfo.top - 9;
|
||||
handleSelect(0);
|
||||
const createRankState = () => ({
|
||||
list: [],
|
||||
page: 0,
|
||||
pageSize: PAGE_SIZE,
|
||||
loading: false,
|
||||
noMore: false,
|
||||
loaded: false,
|
||||
scrollTop: 0,
|
||||
myData: null,
|
||||
myDataLoaded: false,
|
||||
});
|
||||
|
||||
const handleSelect = (index) => {
|
||||
selectedIndex.value = index;
|
||||
myData.value = {};
|
||||
currentList.value = [];
|
||||
if (index === 0) {
|
||||
currentList.value = rankData.value.rank;
|
||||
} else if (index === 1) {
|
||||
currentList.value = rankData.value.mvpRank;
|
||||
} else if (index === 2) {
|
||||
currentList.value = rankData.value.ringRank;
|
||||
const rankTabs = [
|
||||
{
|
||||
key: "score",
|
||||
title: "积分榜",
|
||||
subTitle: "排位赛积分",
|
||||
listApi: getScoreRankList,
|
||||
myApi: getMyScoreRank,
|
||||
},
|
||||
{
|
||||
key: "mvp",
|
||||
title: "MVP榜",
|
||||
subTitle: "MVP次数",
|
||||
listApi: getMvpRankList,
|
||||
myApi: getMyMvpRank,
|
||||
},
|
||||
{
|
||||
key: "tenRing",
|
||||
title: "十环榜",
|
||||
subTitle: "十环次数",
|
||||
listApi: getTenRingRankList,
|
||||
myApi: getMyTenRingRank,
|
||||
},
|
||||
];
|
||||
|
||||
// 解析 ranking 页面传入的榜单参数,进入页面时默认选中对应 tab。
|
||||
const getTabIndexByRouteParam = (tab) => {
|
||||
if (tab === "mvp") return 1;
|
||||
if (tab === "tenRing") return 2;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const rankStates = ref({
|
||||
score: createRankState(),
|
||||
mvp: createRankState(),
|
||||
tenRing: createRankState(),
|
||||
});
|
||||
|
||||
const selectedIndex = ref(0);
|
||||
const initialTabIndex = ref(0);
|
||||
const pageMounted = ref(false);
|
||||
const initializedFromRoute = ref(false);
|
||||
const addBg = ref(false);
|
||||
const currentScrollTop = ref(0);
|
||||
const restoreScrollTop = ref(0);
|
||||
const tabSwitchAnimating = ref(false);
|
||||
const suppressScrollSync = ref(false);
|
||||
const suppressLoadMore = ref(false);
|
||||
const stickyTabsTop = capsuleHeight + 50;
|
||||
const stickyTabsActive = ref(false);
|
||||
const tabsStickyThreshold = ref(0);
|
||||
const tabsStickyReady = ref(false);
|
||||
const tabsHeight = ref(0);
|
||||
|
||||
const getTabConfig = (index = selectedIndex.value) => rankTabs[index];
|
||||
const getTabKey = (index = selectedIndex.value) => getTabConfig(index).key;
|
||||
|
||||
// 统一提取榜单接口返回的列表数据,兼容数组和对象两种返回格式。
|
||||
const getRankListFromResponse = (result) => {
|
||||
if (Array.isArray(result)) return result;
|
||||
if (Array.isArray(result?.list)) return result.list;
|
||||
if (Array.isArray(result?.items)) return result.items;
|
||||
return [];
|
||||
};
|
||||
|
||||
// 为当前登录用户构造默认的个人榜单信息,避免接口未返回时底部区域缺数据。
|
||||
const buildDefaultMyData = () => ({
|
||||
rank: null,
|
||||
userId: user.value.id,
|
||||
name: user.value.nickName,
|
||||
avatar: user.value.avatar,
|
||||
vip: user.value.vip,
|
||||
sVip: user.value.sVip,
|
||||
totalScore: 0,
|
||||
mvpCount: 0,
|
||||
tenRings: 0,
|
||||
totalGames: 0,
|
||||
totalCount: 0,
|
||||
rankName: user.value.lvlName,
|
||||
rankLvl: user.value.rankLvl,
|
||||
});
|
||||
|
||||
const currentTabKey = computed(() => getTabKey(selectedIndex.value));
|
||||
const currentState = computed(() => rankStates.value[currentTabKey.value]);
|
||||
const currentList = computed(() => currentState.value.list);
|
||||
const currentSubTitle = computed(() => getTabConfig(selectedIndex.value).subTitle);
|
||||
const currentMyData = computed(() => {
|
||||
if (!user.value.id) return null;
|
||||
return currentState.value.myData || buildDefaultMyData();
|
||||
});
|
||||
|
||||
// 统一格式化段位和场次文案,兼容不同接口的字段命名。
|
||||
const formatLevelText = (item = {}) => {
|
||||
const levelName = item.rankName || getLvlName(item.rankLvl) || "暂无段位";
|
||||
const totalGames = item.totalGames ?? item.TotalGames ?? 0;
|
||||
return `${levelName},${totalGames}场`;
|
||||
};
|
||||
|
||||
// 统一读取榜单项的排名字段,没有后端 rank 时回退到前端序号。
|
||||
const getDisplayRank = (item = {}, index = 0) => {
|
||||
return item.rank ?? index + 1;
|
||||
};
|
||||
|
||||
// 底部个人排名在未上榜时展示占位符,而不是空白。
|
||||
const getDisplayMyRank = (item = {}) => {
|
||||
return item.rank ?? "-";
|
||||
};
|
||||
|
||||
const getScoreValue = (item = {}) => item.totalScore ?? 0;
|
||||
const getMvpValue = (item = {}) => item.mvpCount ?? item.totalScore ?? 0;
|
||||
const getTenRingValue = (item = {}) =>
|
||||
item.tenRings ?? item.TenRings ?? item.totalScore ?? 0;
|
||||
|
||||
// 根据当前选中的榜单类型,读取对应的展示值。
|
||||
const getRankValue = (item = {}, index = selectedIndex.value) => {
|
||||
if (index === 0) return getScoreValue(item);
|
||||
if (index === 1) return getMvpValue(item);
|
||||
return getTenRingValue(item);
|
||||
};
|
||||
|
||||
const getRankUnit = (index = selectedIndex.value) => {
|
||||
if (index === 0) return "分";
|
||||
return "次";
|
||||
};
|
||||
|
||||
const isMember = (item = {}) => item.vip === true || item.sVip === true;
|
||||
|
||||
const getMemberNicknameClass = (item = {}) => [
|
||||
"rank-list-player-name",
|
||||
"member-nickname",
|
||||
item.vip === true && item.sVip !== true ? "member-nickname--vip" : "",
|
||||
item.sVip === true ? "member-nickname--svip" : "",
|
||||
];
|
||||
|
||||
// 统一设置页面当前的视觉滚动状态,避免吸顶和顶部背景不同步。
|
||||
const syncScrollVisualState = (scrollTop = 0) => {
|
||||
currentScrollTop.value = scrollTop;
|
||||
addBg.value = scrollTop > 100;
|
||||
if (!tabsStickyReady.value) {
|
||||
stickyTabsActive.value = false;
|
||||
return;
|
||||
}
|
||||
if (user.value.id) {
|
||||
currentList.value.some((item) => {
|
||||
if (item.userId === user.value.id) {
|
||||
myData.value = item;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!myData.value.userId) {
|
||||
myData.value = {
|
||||
userId: user.value.id,
|
||||
TotalGames: 0,
|
||||
totalScore: 0,
|
||||
mvpCount: 0,
|
||||
TenRings: 0,
|
||||
};
|
||||
stickyTabsActive.value = scrollTop >= tabsStickyThreshold.value;
|
||||
};
|
||||
|
||||
// 只保留一条滚动恢复链路:从当前滚动位置平滑滚到目标位置,避免多套控制同时生效造成闪烁。
|
||||
const applyScrollPosition = async (
|
||||
fromScrollTop = currentScrollTop.value,
|
||||
toScrollTop = 0,
|
||||
withAnimation = false
|
||||
) => {
|
||||
tabSwitchAnimating.value = withAnimation;
|
||||
restoreScrollTop.value = fromScrollTop;
|
||||
await nextTick();
|
||||
restoreScrollTop.value = toScrollTop;
|
||||
syncScrollVisualState(toScrollTop);
|
||||
};
|
||||
|
||||
// 请求指定榜单的某一页数据,只有当前榜单会追加分页,不影响其他榜单的浏览状态。
|
||||
const loadRankPage = async (tabKey, { reset = false } = {}) => {
|
||||
const state = rankStates.value[tabKey];
|
||||
const config = rankTabs.find((item) => item.key === tabKey);
|
||||
if (!config || state.loading) return;
|
||||
if (!reset && state.noMore) return;
|
||||
|
||||
const nextPage = reset ? 1 : state.page + 1;
|
||||
state.loading = true;
|
||||
if (reset) state.noMore = false;
|
||||
|
||||
try {
|
||||
const result = await config.listApi(undefined, nextPage, PAGE_SIZE);
|
||||
const list = getRankListFromResponse(result);
|
||||
state.list = reset ? list : state.list.concat(list);
|
||||
state.page = nextPage;
|
||||
state.loaded = true;
|
||||
state.noMore = list.length < PAGE_SIZE;
|
||||
} catch (error) {
|
||||
if (reset) {
|
||||
state.list = [];
|
||||
state.page = 0;
|
||||
state.loaded = false;
|
||||
state.noMore = false;
|
||||
}
|
||||
uni.showToast({
|
||||
title: "排行榜加载失败",
|
||||
icon: "none",
|
||||
});
|
||||
console.error("load rank page error", error);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onScrollView = (e) => {
|
||||
addBg.value = e.detail.scrollTop > 100;
|
||||
// 每个榜单独立请求一次个人排名信息,切回该榜单时直接复用,避免打断浏览上下文。
|
||||
const loadMyRankData = async (tabKey) => {
|
||||
if (!user.value.id) return;
|
||||
|
||||
const state = rankStates.value[tabKey];
|
||||
const config = rankTabs.find((item) => item.key === tabKey);
|
||||
if (!config || state.myDataLoaded) return;
|
||||
|
||||
try {
|
||||
const result = await config.myApi();
|
||||
state.myData = {
|
||||
...buildDefaultMyData(),
|
||||
...(result || {}),
|
||||
};
|
||||
} catch (error) {
|
||||
state.myData = buildDefaultMyData();
|
||||
console.error("load my rank data error", error);
|
||||
} finally {
|
||||
state.myDataLoaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
// 首次进入或切换到未加载过的榜单时,初始化它的分页数据和个人横条数据。
|
||||
const ensureTabReady = async (index = selectedIndex.value) => {
|
||||
const tabKey = getTabKey(index);
|
||||
const state = rankStates.value[tabKey];
|
||||
|
||||
if (!state.loaded) {
|
||||
await loadRankPage(tabKey, { reset: true });
|
||||
}
|
||||
|
||||
if (user.value.id && !state.myDataLoaded) {
|
||||
await loadMyRankData(tabKey);
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
return state.scrollTop || 0;
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
initialTabIndex.value = getTabIndexByRouteParam(options.tab);
|
||||
selectedIndex.value = initialTabIndex.value;
|
||||
if (pageMounted.value && !initializedFromRoute.value) {
|
||||
initializePage();
|
||||
}
|
||||
});
|
||||
|
||||
// 页面初始化同时兼容 onLoad 和 onMounted 的先后顺序,确保首屏一定落到路由指定的榜单。
|
||||
const initializePage = async () => {
|
||||
if (initializedFromRoute.value) return;
|
||||
initializedFromRoute.value = true;
|
||||
const nextScrollTop = await ensureTabReady(selectedIndex.value);
|
||||
await applyScrollPosition(0, nextScrollTop, false);
|
||||
setTimeout(() => {
|
||||
measureTabsMetrics();
|
||||
}, 0);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
pageMounted.value = true;
|
||||
await initializePage();
|
||||
});
|
||||
|
||||
// 切换榜单时保留原榜单的列表和滚动位置,切回来后继续从之前的位置浏览。
|
||||
const handleSelect = async (index) => {
|
||||
if (index === selectedIndex.value) return;
|
||||
|
||||
const previousTabKey = currentTabKey.value;
|
||||
rankStates.value[previousTabKey].scrollTop = currentScrollTop.value;
|
||||
const previousScrollTop = currentScrollTop.value;
|
||||
|
||||
suppressScrollSync.value = true;
|
||||
suppressLoadMore.value = true;
|
||||
selectedIndex.value = index;
|
||||
const nextScrollTop = await ensureTabReady(index);
|
||||
await applyScrollPosition(previousScrollTop, nextScrollTop, false);
|
||||
setTimeout(() => {
|
||||
tabSwitchAnimating.value = false;
|
||||
suppressScrollSync.value = false;
|
||||
suppressLoadMore.value = false;
|
||||
}, 220);
|
||||
};
|
||||
|
||||
// 触底后只加载当前榜单的下一页数据,其他榜单的数据和页码保持不变。
|
||||
const loadMore = async () => {
|
||||
if (suppressLoadMore.value) return;
|
||||
await loadRankPage(currentTabKey.value);
|
||||
};
|
||||
|
||||
// 实时记录当前榜单的滚动位置,切换回来时恢复到上一次浏览位置。
|
||||
const onScrollView = (e) => {
|
||||
const scrollTop = e.detail.scrollTop || 0;
|
||||
if (suppressScrollSync.value) return;
|
||||
syncScrollVisualState(scrollTop);
|
||||
rankStates.value[currentTabKey.value].scrollTop = scrollTop;
|
||||
};
|
||||
|
||||
// 计算 tab 在滚动内容中的真实位置和高度,作为吸顶切换的唯一依据。
|
||||
const measureTabsMetrics = () => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query
|
||||
.select("#rank-list-content-start")
|
||||
.boundingClientRect()
|
||||
.select(".rank-tabs-anchor")
|
||||
.boundingClientRect()
|
||||
.exec((res = []) => {
|
||||
const [startRect, rect] = res;
|
||||
if (!startRect || !rect) return;
|
||||
const tabOffset = rect.top - startRect.top;
|
||||
tabsStickyThreshold.value = Math.max(0, tabOffset - 92);
|
||||
tabsHeight.value = rect.height || 0;
|
||||
tabsStickyReady.value = true;
|
||||
syncScrollVisualState(currentScrollTop.value);
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -66,34 +350,43 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
}"
|
||||
>
|
||||
<image
|
||||
v-if="addBg"
|
||||
class="bg-image"
|
||||
src="../static/app-bg.png"
|
||||
:style="{ opacity: addBg ? 1 : 0 }"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<navigator open-type="navigateBack">
|
||||
<image class="header-back" src="../static/back.png" mode="widthFix" />
|
||||
</navigator>
|
||||
<text
|
||||
:style="{ opacity: addBg ? 1 : 0, color: '#fff', fontWeight: 'bold' }"
|
||||
>
|
||||
本赛季排行榜
|
||||
</text>
|
||||
<text :style="{ opacity: addBg ? 1 : 0 }">本赛季排行榜</text>
|
||||
</view>
|
||||
<scroll-view
|
||||
scroll-y
|
||||
:scroll-with-animation="tabSwitchAnimating"
|
||||
:scroll-top="restoreScrollTop"
|
||||
@scroll="onScrollView"
|
||||
:style="{ height: myData.userId ? '90vh' : '100vh' }"
|
||||
@scrolltolower="loadMore"
|
||||
:style="{ height: user.id ? '90vh' : '100vh' }"
|
||||
>
|
||||
<view id="rank-list-content-start" class="content-start-anchor"></view>
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-09-25/dd1p9b3wcrwnlnghiq.png"
|
||||
mode="widthFix"
|
||||
class="header-bg"
|
||||
@load="measureTabsMetrics"
|
||||
/>
|
||||
<view class="rank-tabs">
|
||||
<view
|
||||
v-if="stickyTabsActive"
|
||||
class="rank-tabs-placeholder"
|
||||
:style="{ height: `${tabsHeight}px` }"
|
||||
/>
|
||||
<view
|
||||
class="rank-tabs rank-tabs-anchor"
|
||||
:class="{ 'rank-tabs-anchor-fixed': stickyTabsActive }"
|
||||
:style="stickyTabsActive ? { top: `${stickyTabsTop}px` } : {}"
|
||||
>
|
||||
<view
|
||||
v-for="(rankType, index) in ['积分榜', 'MVP榜', '十环榜']"
|
||||
:key="index"
|
||||
v-for="(rankType, index) in rankTabs"
|
||||
:key="rankType.key"
|
||||
:style="{
|
||||
fontSize: index === selectedIndex ? '16px' : '14px',
|
||||
color: index === selectedIndex ? '#000' : '#fff',
|
||||
@@ -101,18 +394,18 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
}"
|
||||
@tap="handleSelect(index)"
|
||||
>
|
||||
{{ rankType }}
|
||||
{{ rankType.title }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="rank-list">
|
||||
<view class="rank-list-header">
|
||||
<text>排名</text>
|
||||
<text>用户ID</text>
|
||||
<text>{{ subTitles[selectedIndex] }}</text>
|
||||
<text>{{ currentSubTitle }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(item, index) in currentList"
|
||||
:key="index"
|
||||
:key="`${currentTabKey}-${index}-${item.userId || item.name}`"
|
||||
class="rank-list-item"
|
||||
:style="{
|
||||
backgroundColor: index % 2 === 0 ? '#9898981f' : 'transparent',
|
||||
@@ -154,67 +447,82 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
src="../static/champ3.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-if="index > 2" class="view-crown">{{ index + 1 }}</view>
|
||||
<view v-if="index > 2" class="view-crown">
|
||||
{{ getDisplayRank(item, index) }}
|
||||
</view>
|
||||
<Avatar :src="item.avatar" />
|
||||
<view class="rank-item-content">
|
||||
<text class="truncate">{{ item.name }}</text>
|
||||
<text
|
||||
>{{ getLvlName(item.totalScore) }},{{ item.TotalGames }}场</text
|
||||
>
|
||||
<view v-if="isMember(item)" :class="getMemberNicknameClass(item)">
|
||||
<text class="member-nickname__text">{{ item.name }}</text>
|
||||
<text v-if="item.sVip === true" class="member-nickname__shine">
|
||||
{{ item.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="rank-list-player-name truncate">{{ item.name }}</text>
|
||||
<text>{{ formatLevelText(item) }}</text>
|
||||
</view>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 0">
|
||||
<text class="rank-item-integral">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ item.totalScore }} </text
|
||||
>分
|
||||
</text>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 1">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ item.mvpCount }} </text
|
||||
>次
|
||||
</text>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 2">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ item.TenRings }} </text
|
||||
>次
|
||||
>
|
||||
{{ getRankValue(item) }}
|
||||
</text>
|
||||
{{ getRankUnit() }}
|
||||
</text>
|
||||
</view>
|
||||
<view v-if="!currentList.length" class="no-data">
|
||||
<text>筹备中...</text>
|
||||
<view
|
||||
v-if="currentState.loading && !currentList.length"
|
||||
class="no-data"
|
||||
>
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<view
|
||||
v-else-if="!currentState.loading && !currentList.length"
|
||||
class="no-data"
|
||||
>
|
||||
<text>暂无数据</text>
|
||||
</view>
|
||||
<view v-else class="list-tip">
|
||||
<text v-if="currentState.loading">加载中...</text>
|
||||
<text v-else-if="currentState.noMore">没有更多了</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<view class="my-rank-data" v-if="myData.userId">
|
||||
<view class="my-rank-data" v-if="currentMyData">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-08-05/dbuaf19pf7qd8ps0uh.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text>{{ myData.rank }}</text>
|
||||
<Avatar :src="user.avatar" />
|
||||
<text>{{ getDisplayMyRank(currentMyData) }}</text>
|
||||
<Avatar :src="currentMyData.avatar || user.avatar" />
|
||||
<view class="rank-item-content">
|
||||
<text class="truncate">{{ user.nickName }}</text>
|
||||
<text>{{ user.lvlName }},{{ myData.TotalGames }}场</text>
|
||||
<view
|
||||
v-if="isMember(currentMyData)"
|
||||
:class="getMemberNicknameClass(currentMyData)"
|
||||
>
|
||||
<text class="member-nickname__text">
|
||||
{{ currentMyData.name || user.nickName }}
|
||||
</text>
|
||||
<text
|
||||
v-if="currentMyData.sVip === true"
|
||||
class="member-nickname__shine"
|
||||
>
|
||||
{{ currentMyData.name || user.nickName }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="rank-list-player-name truncate">
|
||||
{{ currentMyData.name || user.nickName }}
|
||||
</text>
|
||||
<text>{{ formatLevelText(currentMyData) }}</text>
|
||||
</view>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 0">
|
||||
<text class="rank-item-integral">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ myData.totalScore || 0 }}</text
|
||||
>分</text
|
||||
>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 1">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ myData.mvpCount || 0 }}</text
|
||||
>次</text
|
||||
>
|
||||
<text class="rank-item-integral" v-if="selectedIndex === 2">
|
||||
<text
|
||||
:style="{ fontSize: '14px', color: '#fff', marginRight: '5px' }"
|
||||
>{{ myData.TenRings || 0 }}</text
|
||||
>次</text
|
||||
>
|
||||
>
|
||||
{{ getRankValue(currentMyData) }}
|
||||
</text>
|
||||
{{ getRankUnit() }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -223,9 +531,16 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content-start-anchor {
|
||||
width: 100%;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.header-bg {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
@@ -233,28 +548,60 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
overflow: hidden;
|
||||
}
|
||||
.header text {
|
||||
transition: all 0.3s ease;
|
||||
line-height: 50px;
|
||||
|
||||
.header-back {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0px 15px;
|
||||
margin-top: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header > image:first-child {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.header > text {
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
transition: all 0.5s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rank-tabs {
|
||||
width: calc(100% - 20px);
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 0 10px;
|
||||
margin-top: -15px;
|
||||
padding: 20rpx 10px;
|
||||
}
|
||||
|
||||
.rank-tabs > view {
|
||||
width: 25%;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.rank-tabs-placeholder {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rank-tabs-anchor-fixed {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
z-index: 11;
|
||||
background: #000000;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -262,11 +609,12 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
width: calc(100% - 20px);
|
||||
color: #fff9;
|
||||
font-size: 12px;
|
||||
margin: 10px;
|
||||
margin: 0 10px 10px 10px;
|
||||
border: 1px solid rgb(255 217 71 / 0.2);
|
||||
border-radius: 10px;
|
||||
background-color: #313131;
|
||||
}
|
||||
|
||||
.rank-list > view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -275,20 +623,25 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rank-list-header {
|
||||
width: calc(100% - 20px) !important;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.rank-list-header > text:nth-child(2) {
|
||||
width: 14%;
|
||||
}
|
||||
|
||||
.rank-list-header > text:last-child {
|
||||
width: 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rank-list-item {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.player-bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -296,12 +649,14 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.player-crown {
|
||||
position: relative;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
margin: 0 15px;
|
||||
}
|
||||
|
||||
.view-crown {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
@@ -313,6 +668,7 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
background-color: #676767;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.rank-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -322,17 +678,32 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
position: relative;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.rank-item-content > text:first-child {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
margin-bottom: 3px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.rank-list-player-name {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
margin-bottom: 3px;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.rank-list-player-name .member-nickname__text,
|
||||
.rank-list-player-name .member-nickname__shine {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.rank-list-item > text:last-child {
|
||||
margin-right: 10px;
|
||||
width: 56px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.my-rank-data {
|
||||
width: calc(100% - 30px);
|
||||
padding: 15px;
|
||||
@@ -346,12 +717,14 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.my-rank-data > image:first-child {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
top: -5px;
|
||||
}
|
||||
|
||||
.my-rank-data > text:nth-child(2) {
|
||||
background-color: #c1a434;
|
||||
position: relative;
|
||||
@@ -363,20 +736,24 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
min-width: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.my-rank-data > text:last-child {
|
||||
position: relative;
|
||||
margin-right: 10px;
|
||||
width: 65px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.my-rank-data > .rank-item-content > text:first-child {
|
||||
color: #fed847;
|
||||
}
|
||||
|
||||
.my-rank-data > .rank-item-integral {
|
||||
color: #fff9;
|
||||
font-size: 12px;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
@@ -386,18 +763,11 @@ const subTitles = ["排位赛积分", "MVP次数", "十环次数"];
|
||||
color: #fff9;
|
||||
font-size: 14px;
|
||||
}
|
||||
.header-back {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0px 15px;
|
||||
margin-top: 5px;
|
||||
position: relative;
|
||||
}
|
||||
.bg-image {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
.list-tip {
|
||||
justify-content: center !important;
|
||||
color: #fff9;
|
||||
font-size: 12px;
|
||||
min-height: 60rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user