Compare commits
191
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88febb92e5 | ||
|
|
c8533de5cf | ||
|
|
74bfd7a327 | ||
|
|
bbfa35e871 | ||
|
|
469487ba73 | ||
|
|
b9bbaba77f | ||
|
|
11eeb98b74 | ||
|
|
96b64e4187 | ||
|
|
45bc324f70 | ||
|
|
2eba5f87d7 | ||
|
|
9a06dcf942 | ||
|
|
35e0ee3ca0 | ||
|
|
59fa317c5a | ||
|
|
09756ae165 | ||
|
|
b89c003313 | ||
|
|
9dda58b19c | ||
|
|
962596e510 | ||
|
|
8a2fbb0780 | ||
|
|
0c0a00f742 | ||
|
|
66fc7fa8eb | ||
|
|
6363dda204 | ||
|
|
9f4fbbe4c9 | ||
|
|
45957d04b1 | ||
|
|
42cfb5c3d8 | ||
|
|
844f72489e | ||
|
|
d2571854bc | ||
|
|
bb224c9f29 | ||
|
|
82c360f1b1 | ||
|
|
63f3cada0f | ||
|
|
75cfcd66ee | ||
|
|
7955b37ec5 | ||
|
|
9e6ad4cf50 | ||
|
|
6c55d32f90 | ||
|
|
d60c32506f | ||
|
|
63fb4ae85a | ||
|
|
2628a17b3f | ||
|
|
e7f073ed7c | ||
|
|
d662db9465 | ||
|
|
23655102d3 | ||
|
|
fbb2076a77 | ||
|
|
8c96a4cb73 | ||
|
|
6a78093d31 | ||
|
|
4835993639 | ||
|
|
3c45ef78ac | ||
|
|
ab70c8c78e | ||
|
|
fac2576b65 | ||
|
|
360da2a1d7 | ||
|
|
e96537b21b | ||
|
|
8c834d299b | ||
|
|
b386990475 | ||
|
|
3ab1ad59bd | ||
|
|
cfdb169329 | ||
|
|
4245a3f947 | ||
|
|
d6f914ca7e | ||
|
|
959244c03a | ||
|
|
3a51142bd3 | ||
|
|
07dc8a250c | ||
|
|
f34800798d | ||
|
|
72fd8b134e | ||
|
|
30ab1c6775 | ||
|
|
02f30e88ba | ||
|
|
e6f11d3684 | ||
|
|
c5618fb60b | ||
|
|
f316d52cec | ||
|
|
d8d2f9e7e7 | ||
|
|
ef499e1448 | ||
|
|
c0d061d7d5 | ||
|
|
dff210462f | ||
|
|
3c5754b3fd | ||
|
|
a78ab1daeb | ||
|
|
886faa0007 | ||
|
|
b15c37cb4a | ||
|
|
970a9874b4 | ||
|
|
f64a6b5c95 | ||
|
|
621397e25d | ||
|
|
fa7ad8f538 | ||
|
|
b8c5f3dd91 | ||
|
|
af7f70ba80 | ||
|
|
6d1a910051 | ||
|
|
1c7cf74eb9 | ||
|
|
e9023ecc9f | ||
|
|
080af14be8 | ||
|
|
1796d4aa40 | ||
|
|
4b39107453 | ||
|
|
3e090e5b33 | ||
|
|
98ca638b63 | ||
|
|
b5373bee31 | ||
|
|
58a7bb2017 | ||
|
|
82eedc3ab8 | ||
|
|
ac4d8dfb38 | ||
|
|
ad8bc1f152 | ||
|
|
725a3ddcb9 | ||
|
|
33187e0cc3 | ||
|
|
bf6b87b59c | ||
|
|
12729a1cef | ||
|
|
f8efe4c4a4 | ||
|
|
af07d09ab6 | ||
|
|
e0d976fcce | ||
|
|
01ac4f9c03 | ||
|
|
095884d7fc | ||
|
|
348d490de8 | ||
|
|
a7897af861 | ||
|
|
d3e0307854 | ||
|
|
fd7c4b4bbf | ||
|
|
efb186d242 | ||
|
|
0a678cb387 | ||
|
|
3c7467fbdf | ||
|
|
6aa3c6fd6c | ||
|
|
8b11e41957 | ||
|
|
8c6374b2c5 | ||
|
|
78a495de60 | ||
|
|
ef13ff6a2b | ||
|
|
8b7a94b8c4 | ||
|
|
3e99bb8ad9 | ||
|
|
40b94dbad8 | ||
|
|
422a93dbe2 | ||
|
|
5c74edf001 | ||
|
|
937308e51d | ||
|
|
165f1a40bd | ||
|
|
35c49bbb10 | ||
|
|
ad0fb0b337 | ||
|
|
02290615e8 | ||
|
|
df628b48c6 | ||
|
|
ca6bba4dff | ||
|
|
c435372d33 | ||
|
|
4c32bbfb13 | ||
|
|
b6b517be9e | ||
|
|
4bea563109 | ||
|
|
bd352c04a8 | ||
|
|
caa6ed4284 | ||
|
|
4abb0de1e7 | ||
|
|
5fe998adf2 | ||
|
|
7591058786 | ||
|
|
8d8ede5397 | ||
|
|
045827cb33 | ||
|
|
0bf4773400 | ||
|
|
4ce2e6a7c4 | ||
|
|
69ed5bda1b | ||
|
|
5bab30d2e4 | ||
|
|
8b8366f30f | ||
|
|
8b2ea24f38 | ||
|
|
52c5b9504a | ||
|
|
c30aa45e5b | ||
|
|
5cf243d187 | ||
|
|
68f13910a3 | ||
|
|
bfdd40ec93 | ||
|
|
5581c117e2 | ||
|
|
996754be7f | ||
|
|
50a2829519 | ||
|
|
5e95e01c71 | ||
|
|
54e5427a77 | ||
|
|
9b47b313a7 | ||
|
|
350c6373ab | ||
|
|
048b0e9fa9 | ||
|
|
39d34d5bff | ||
|
|
37f4f0c019 | ||
|
|
022683aff1 | ||
|
|
e9cd9c8c9f | ||
|
|
0d5866b82a | ||
|
|
73a41ab897 | ||
|
|
32c37e283f | ||
|
|
209103742d | ||
|
|
27d11a8129 | ||
|
|
c9ea368a19 | ||
|
|
b5e7e72104 | ||
|
|
7a00c1bb1f | ||
|
|
72518fa17e | ||
|
|
f5497c534d | ||
|
|
b3fc11f1b1 | ||
|
|
8b25a10d4c | ||
|
|
2a5394155a | ||
|
|
0e82416800 | ||
|
|
ef5b97530a | ||
|
|
e6d00e7ea9 | ||
|
|
d932ce3dea | ||
|
|
b8d1654476 | ||
|
|
c5a8100c38 | ||
|
|
8ef64f8f42 | ||
|
|
18afba01ec | ||
|
|
2780d1a6df | ||
|
|
2a53f6739e | ||
|
|
1e568db861 | ||
|
|
fe8b38bc6f | ||
|
|
bae31add22 | ||
|
|
465b9c8dc7 | ||
|
|
3ff11df1d7 | ||
|
|
21d8d0fbdb | ||
|
|
fc7149121b | ||
|
|
8061ddbed5 | ||
|
|
bb50c7ca10 | ||
|
|
1bca5977c1 |
@@ -10,6 +10,7 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
.history
|
.history
|
||||||
.github
|
.github
|
||||||
|
.claude
|
||||||
openspec
|
openspec
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
docs
|
docs
|
||||||
|
|||||||
Vendored
+7
-1
@@ -1,3 +1,9 @@
|
|||||||
{
|
{
|
||||||
"i18n-ally.localesPaths": []
|
"i18n-ally.localesPaths": [],
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/dist/**": true
|
||||||
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/dist/**": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -264,6 +264,7 @@ AI 应主动:
|
|||||||
* 少解释
|
* 少解释
|
||||||
* 优先 patch
|
* 优先 patch
|
||||||
* 优先 diff
|
* 优先 diff
|
||||||
|
* 写好中文注释
|
||||||
|
|
||||||
除非用户明确要求:
|
除非用户明确要求:
|
||||||
否则不要输出完整项目。
|
否则不要输出完整项目。
|
||||||
|
|||||||
@@ -114,3 +114,133 @@ git push origin test
|
|||||||
3. **体验版发布前确认代码已提交**,避免遗漏
|
3. **体验版发布前确认代码已提交**,避免遗漏
|
||||||
4. **开发分支命名建议**:`feature/姓名-功能名`,如 `feature/zhangsan-login`
|
4. **开发分支命名建议**:`feature/姓名-功能名`,如 `feature/zhangsan-login`
|
||||||
5. **删除已合并的开发分支**:`git branch -d feature/your-name-work`
|
5. **删除已合并的开发分支**:`git branch -d feature/your-name-work`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、比赛服 Proto 更新流程
|
||||||
|
|
||||||
|
### 1. 新开发人员快速接手
|
||||||
|
|
||||||
|
首次拉取项目后,先安装依赖并确认当前协议与运行时解码器一致:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run proto:check
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `proto:check` 提示协议不同步,先执行 `npm run proto:generate`,不要直接修改生成区块。
|
||||||
|
|
||||||
|
### 2. 相关文件职责
|
||||||
|
|
||||||
|
| 文件 | 职责 | 修改规则 |
|
||||||
|
|------|------|----------|
|
||||||
|
| `src/utils/match.min.js` | 后端比赛服协议描述,是生成字段表的输入文件 | 后端协议更新时整体替换;禁止在小程序运行时代码中导入 |
|
||||||
|
| `scripts/generate-match-schema.mjs` | 解析协议、校验字段并生成运行时字段表 | 只有新增通用协议能力时才修改 |
|
||||||
|
| `src/utils/matchProtocol.js` | 小程序实际使用的 protobuf 编解码器 | `<match-schema-generated>` 标记区间内禁止手动修改 |
|
||||||
|
| `src/matchWebsocket.js` | 服务端消息路由、ACK 和业务事件分发 | 新增消息业务行为时人工接入 |
|
||||||
|
| `src/utils/matchAdapter.js` | 将解码结果从 snake_case 统一转换为 camelCase | 页面继续使用适配后的字段名 |
|
||||||
|
|
||||||
|
`match.min.js` 不是小程序运行时解码器。运行时仍使用 `protobufjs/minimal.js` 的 `Reader/Writer`,`int64` 字段统一保留为字符串,避免大整数精度丢失。
|
||||||
|
|
||||||
|
### 3. 日常协议更新步骤
|
||||||
|
|
||||||
|
```text
|
||||||
|
替换 src/utils/match.min.js
|
||||||
|
↓
|
||||||
|
执行 npm run proto:update
|
||||||
|
↓
|
||||||
|
检查生成差异和业务接入范围
|
||||||
|
↓
|
||||||
|
执行 git diff --check
|
||||||
|
↓
|
||||||
|
微信开发者工具检查包体并上传
|
||||||
|
```
|
||||||
|
|
||||||
|
具体操作:
|
||||||
|
|
||||||
|
1. 从后端获取最新的 `match.min.js`,整体覆盖 `src/utils/match.min.js`。
|
||||||
|
2. 执行推荐命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run proto:update
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 该命令会依次完成:
|
||||||
|
- 解析 `match.min.js`
|
||||||
|
- 更新 `matchProtocol.js` 内的生成区块
|
||||||
|
- 校验生成区块与协议源文件一致
|
||||||
|
- 执行微信小程序正式构建
|
||||||
|
4. 检查 Git 差异。普通字段更新通常只应涉及:
|
||||||
|
- `src/utils/match.min.js`
|
||||||
|
- `src/utils/matchProtocol.js` 的生成区块
|
||||||
|
5. 如果新增了消息业务行为,再单独检查 `src/matchWebsocket.js`、页面或组件的接入改动。
|
||||||
|
6. 执行空白和换行检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
7. 打开微信开发者工具,导入 `dist/build/mp-weixin`,检查代码依赖分析和主包体积后再上传。
|
||||||
|
|
||||||
|
### 4. Proto 命令说明
|
||||||
|
|
||||||
|
| 命令 | 用途 | 是否修改文件 |
|
||||||
|
|------|------|--------------|
|
||||||
|
| `npm run proto:generate` | 根据 `match.min.js` 更新生成区块 | 是 |
|
||||||
|
| `npm run proto:check` | 检查生成区块是否与协议同步 | 否 |
|
||||||
|
| `npm run proto:update` | 执行生成,然后运行正式构建 | 是,日常更新推荐使用 |
|
||||||
|
| `npm run dev` | 启动开发构建,启动前自动执行 `proto:check` | 协议不同步时直接中止 |
|
||||||
|
| `npm run build` | 执行正式构建,构建前自动执行 `proto:check` | 协议不同步时直接中止 |
|
||||||
|
|
||||||
|
`npm run dev` 和 `npm run build` 只负责检查,不会自动改写协议;发现不同步时应执行 `npm run proto:generate` 或 `npm run proto:update`。
|
||||||
|
|
||||||
|
### 5. 自动生成与人工接入边界
|
||||||
|
|
||||||
|
以下内容由生成器自动处理:
|
||||||
|
|
||||||
|
- 服务端和客户端消息枚举
|
||||||
|
- 普通 scalar 字段
|
||||||
|
- 嵌套 message
|
||||||
|
- repeated message
|
||||||
|
- 当前解码器支持的 map
|
||||||
|
- snake_case 字段名和 oneof 兼容信息
|
||||||
|
|
||||||
|
以下情况仍需人工处理:
|
||||||
|
|
||||||
|
- **新增服务端消息类型**:枚举会自动生成,但 `matchWebsocket.js` 的路由、ACK、音频或页面事件仍需接入。
|
||||||
|
- **新增客户端指令**:枚举会自动生成,但发送函数、字段编码和业务调用入口仍需接入。
|
||||||
|
- **修改既有字段编号或类型**:属于协议兼容性变更,必须先与后端确认,不能只看构建是否通过。
|
||||||
|
- **新增解码器不支持的类型**:生成器会直接报错,需要同时扩展生成器和 `matchProtocol.js` 的通用解码能力。
|
||||||
|
|
||||||
|
当前生成器会拒绝不支持的 scalar、map key、字段规则以及 packed scalar repeated,避免继续构建后静默丢字段。
|
||||||
|
|
||||||
|
### 6. 常见报错处理
|
||||||
|
|
||||||
|
| 报错或现象 | 原因 | 处理方式 |
|
||||||
|
|------------|------|----------|
|
||||||
|
| 生成区块与 `match.min.js` 不同步 | 替换协议后没有重新生成 | 执行 `npm run proto:update` |
|
||||||
|
| 找不到 `Root.create(...)` | 后端提供的文件格式发生变化或文件不完整 | 停止更新,确认协议文件来源和生成格式 |
|
||||||
|
| 使用了不支持的类型、规则或 map key | 新协议超出当前通用解码器能力 | 不要手补生成区块,先扩展生成器和解码器并补充验证 |
|
||||||
|
| packed scalar repeated 不支持 | protobuf 默认可能使用 packed 编码,当前通用解码器未覆盖 | 增加 packed 解码能力后再重新生成 |
|
||||||
|
| `matchProtocol.js` 缺少生成区块标记 | 标记被误删或生成区块被手改 | 恢复 `<match-schema-generated>` 标记,重新执行生成 |
|
||||||
|
| 协议生成成功但构建失败 | 问题位于项目构建或业务代码,不是字段表同步 | 保留生成结果,按构建错误继续定位 |
|
||||||
|
|
||||||
|
### 7. 禁止事项与验收清单
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- 禁止在运行时代码中导入 `match.min.js`。
|
||||||
|
- 禁止手动修改 `<match-schema-generated>` 与 `</match-schema-generated>` 之间的内容。
|
||||||
|
- 禁止绕过 `proto:check` 后直接上传。
|
||||||
|
- 禁止把 `int64` 字段直接转换为普通 `Number`。
|
||||||
|
- 禁止把“枚举已经生成”等同于“业务消息已经完成接入”。
|
||||||
|
|
||||||
|
每次协议更新至少确认:
|
||||||
|
|
||||||
|
1. `npm run proto:check` 通过。
|
||||||
|
2. `npm run build` 通过,或直接确认 `npm run proto:update` 已完整通过。
|
||||||
|
3. `git diff --check` 通过。
|
||||||
|
4. 生成区块之外没有意外改动。
|
||||||
|
5. 新消息已完成必要的 WebSocket 路由和页面验证。
|
||||||
|
6. 微信开发者工具中的主包、分包体积符合上传限制。
|
||||||
|
|||||||
+2
-1
@@ -12,5 +12,6 @@
|
|||||||
},
|
},
|
||||||
"vueCompilerOptions": {
|
"vueCompilerOptions": {
|
||||||
"plugins": ["@uni-helper/uni-types/volar-plugin"]
|
"plugins": ["@uni-helper/uni-types/volar-plugin"]
|
||||||
}
|
},
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+628
@@ -26,6 +26,8 @@
|
|||||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||||
"@dcloudio/uni-ui": "^1.5.11",
|
"@dcloudio/uni-ui": "^1.5.11",
|
||||||
"pinia": "2.0.36",
|
"pinia": "2.0.36",
|
||||||
|
"pinia-plugin-persistedstate": "3.2.1",
|
||||||
|
"protobufjs": "^8.7.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-i18n": "^9.1.9"
|
"vue-i18n": "^9.1.9"
|
||||||
},
|
},
|
||||||
@@ -2422,6 +2424,70 @@
|
|||||||
"vite": "^5.2.8"
|
"vite": "^5.2.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.20.2",
|
"version": "0.20.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
||||||
@@ -2438,6 +2504,294 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.20.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz",
|
||||||
|
"integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@intlify/core-base": {
|
"node_modules/@intlify/core-base": {
|
||||||
"version": "9.1.9",
|
"version": "9.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.1.9.tgz",
|
||||||
@@ -3424,6 +3778,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.39.0",
|
"version": "4.39.0",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz",
|
||||||
@@ -3437,6 +3817,227 @@
|
|||||||
"darwin"
|
"darwin"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
|
"version": "4.39.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz",
|
||||||
|
"integrity": "sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@sinonjs/commons": {
|
"node_modules/@sinonjs/commons": {
|
||||||
"version": "1.8.6",
|
"version": "1.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz",
|
||||||
@@ -7535,6 +8136,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/long": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -8222,6 +8829,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pinia-plugin-persistedstate": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-MK++8LRUsGF7r45PjBFES82ISnPzyO6IZx3CH5vyPseFLZCk1g2kgx6l/nW8pEBKxxd4do0P6bJw+mUSZIEZUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pinia": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pirates": {
|
"node_modules/pirates": {
|
||||||
"version": "4.0.7",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||||
@@ -8530,6 +9146,18 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/protobufjs": {
|
||||||
|
"version": "8.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.0.tgz",
|
||||||
|
"integrity": "sha512-uu52JNxLh3vsL7tXU/h0gDaywufvuUCTbGSi0NKQKBZ2ZopkmrWQJSQO/EFqzu/5YhiwgVM8rq/a/iVpx4eZ0g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"long": "^5.3.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"proto:generate": "node scripts/generate-match-schema.mjs",
|
||||||
|
"proto:check": "node scripts/generate-match-schema.mjs --check",
|
||||||
|
"proto:update": "npm run proto:generate && npm run build",
|
||||||
|
"predev": "npm run proto:check",
|
||||||
"dev": "uni -p mp-weixin",
|
"dev": "uni -p mp-weixin",
|
||||||
|
"prebuild": "npm run proto:check",
|
||||||
"build": "uni build -p mp-weixin"
|
"build": "uni build -p mp-weixin"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -26,6 +31,7 @@
|
|||||||
"@dcloudio/uni-ui": "^1.5.11",
|
"@dcloudio/uni-ui": "^1.5.11",
|
||||||
"pinia": "2.0.36",
|
"pinia": "2.0.36",
|
||||||
"pinia-plugin-persistedstate": "3.2.1",
|
"pinia-plugin-persistedstate": "3.2.1",
|
||||||
|
"protobufjs": "^8.7.0",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-i18n": "^9.1.9"
|
"vue-i18n": "^9.1.9"
|
||||||
},
|
},
|
||||||
|
|||||||
+242
-1
@@ -3,7 +3,248 @@
|
|||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"libVersion": "3.7.7",
|
"libVersion": "3.7.7",
|
||||||
"packOptions": {
|
"packOptions": {
|
||||||
"ignore": [],
|
"ignore": [
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/common/dialog-light.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/common/dialog-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/common/dialog-icon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/prompt-bg-square.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/coach-comment.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/screen-hint-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/red-team-win.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/blue-team-win.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/my-grow.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/gold-shining.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/bow-target.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/matching-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/versus.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/point-champion.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/my-practise.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/shining-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/donate.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/friend-battle.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/user-upgrade.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/finish-frame.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/vip/svip-jian.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/battle-header.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/battle-header-melee.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/player-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/mvp-blue.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/finish-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/2unfinish-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/have-no-device.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/device-icon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/unfinish-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/test-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/mvp-red.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/vip/svip-lie.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/mvp-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/rank/battle-choose.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/back-to-game-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/complete-light1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/complete-light2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/title-2v2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/tab-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/scan.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/choose-battle-mode.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/shooter.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/my-growth.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/juezhanbang.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/title-3v3.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/point-book-tip-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/reward-us.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/rank/star.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/tab-point-book.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/pk-icon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/first-try.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/row-yellow-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/room-notfound-title.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/title-mvp.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/long-bubble-tall.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/battle-result.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "file",
|
||||||
|
"value": "static/tab-mall.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"value": "static/training-home"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"value": "static/training-difficulty-design"
|
||||||
|
}
|
||||||
|
],
|
||||||
"include": []
|
"include": []
|
||||||
},
|
},
|
||||||
"setting": {
|
"setting": {
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = resolve(scriptDirectory, "..");
|
||||||
|
const sourcePath = resolve(projectRoot, "src/utils/match.min.js");
|
||||||
|
const runtimePath = resolve(projectRoot, "src/utils/matchProtocol.js");
|
||||||
|
const generatedStartMarker = "// <match-schema-generated>";
|
||||||
|
const generatedEndMarker = "// </match-schema-generated>";
|
||||||
|
|
||||||
|
const supportedScalarKinds = new Set([
|
||||||
|
"int32",
|
||||||
|
"int64",
|
||||||
|
"float",
|
||||||
|
"double",
|
||||||
|
"bool",
|
||||||
|
"string",
|
||||||
|
"bytes",
|
||||||
|
]);
|
||||||
|
const supportedMapKeyKinds = new Set(["int32", "int64", "bool", "string"]);
|
||||||
|
// 新版描述文件未携带旧协议的 oneof 元数据,保留现有解码结果中的 payload 标识。
|
||||||
|
const compatibilityOneofs = {
|
||||||
|
ServerMessage: {
|
||||||
|
match_info: "payload",
|
||||||
|
shoot_data: "payload",
|
||||||
|
practice_info: "payload",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value) {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractRootCreateArgument(source) {
|
||||||
|
const match = /(?:\$protobuf\.)?Root\.create\s*\(/.exec(source);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("match.min.js 中未找到 Root.create(...) 协议描述");
|
||||||
|
}
|
||||||
|
|
||||||
|
const openIndex = source.indexOf("(", match.index);
|
||||||
|
let depth = 0;
|
||||||
|
let quote = "";
|
||||||
|
let escaped = false;
|
||||||
|
let lineComment = false;
|
||||||
|
let blockComment = false;
|
||||||
|
|
||||||
|
for (let index = openIndex; index < source.length; index += 1) {
|
||||||
|
const char = source[index];
|
||||||
|
const next = source[index + 1];
|
||||||
|
|
||||||
|
if (lineComment) {
|
||||||
|
if (char === "\n") lineComment = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (blockComment) {
|
||||||
|
if (char === "*" && next === "/") {
|
||||||
|
blockComment = false;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (quote) {
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === quote) {
|
||||||
|
quote = "";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"' || char === "'" || char === "`") {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "/" && next === "/") {
|
||||||
|
lineComment = true;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "/" && next === "*") {
|
||||||
|
blockComment = true;
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "(") {
|
||||||
|
depth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === ")") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) {
|
||||||
|
return source.slice(openIndex + 1, index).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("match.min.js 中的 Root.create(...) 括号不完整");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripQuotedText(source) {
|
||||||
|
let output = "";
|
||||||
|
let quote = "";
|
||||||
|
let escaped = false;
|
||||||
|
|
||||||
|
for (const char of source) {
|
||||||
|
if (quote) {
|
||||||
|
output += " ";
|
||||||
|
if (escaped) {
|
||||||
|
escaped = false;
|
||||||
|
} else if (char === "\\") {
|
||||||
|
escaped = true;
|
||||||
|
} else if (char === quote) {
|
||||||
|
quote = "";
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
quote = char;
|
||||||
|
output += " ";
|
||||||
|
} else {
|
||||||
|
output += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDescriptor(argumentSource) {
|
||||||
|
if (!argumentSource.startsWith("{") || !argumentSource.endsWith("}")) {
|
||||||
|
throw new Error("Root.create(...) 参数不是静态对象字面量");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(argumentSource);
|
||||||
|
} catch {
|
||||||
|
// 兼容旧版 pbjs 生成的未加引号对象键;只允许静态对象语法。
|
||||||
|
const syntaxOnly = stripQuotedText(argumentSource);
|
||||||
|
if (/[();`=]/.test(syntaxOnly)) {
|
||||||
|
throw new Error("旧版协议描述包含非静态表达式,已拒绝执行");
|
||||||
|
}
|
||||||
|
const descriptor = vm.runInNewContext(
|
||||||
|
`(${argumentSource})`,
|
||||||
|
Object.create(null),
|
||||||
|
{ timeout: 1000 }
|
||||||
|
);
|
||||||
|
return JSON.parse(JSON.stringify(descriptor));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findProtocolNamespace(node, path = []) {
|
||||||
|
if (!isRecord(node)) return null;
|
||||||
|
const entries = isRecord(node.nested) ? node.nested : node;
|
||||||
|
|
||||||
|
if (isRecord(entries.ServerMessage?.fields) && isRecord(entries.ClientMessage?.fields)) {
|
||||||
|
return { entries, path };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, child] of Object.entries(entries)) {
|
||||||
|
if (!isRecord(child) || isRecord(child.fields)) continue;
|
||||||
|
const found = findProtocolNamespace(child, [...path, name]);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEnumValues(definition) {
|
||||||
|
if (!isRecord(definition)) return null;
|
||||||
|
const candidate = isRecord(definition.values) ? definition.values : definition;
|
||||||
|
const entries = Object.entries(candidate);
|
||||||
|
if (entries.length === 0 || entries.some(([, value]) => !Number.isInteger(value))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Object.fromEntries(entries.sort((left, right) => left[1] - right[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTypeName(type) {
|
||||||
|
return String(type || "")
|
||||||
|
.replace(/^\./, "")
|
||||||
|
.split(".")
|
||||||
|
.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOneofByField(definition) {
|
||||||
|
const result = new Map();
|
||||||
|
if (!isRecord(definition.oneofs)) return result;
|
||||||
|
|
||||||
|
for (const [groupName, groupDefinition] of Object.entries(definition.oneofs)) {
|
||||||
|
const fieldNames = Array.isArray(groupDefinition)
|
||||||
|
? groupDefinition
|
||||||
|
: groupDefinition?.oneof;
|
||||||
|
if (!Array.isArray(fieldNames)) continue;
|
||||||
|
for (const fieldName of fieldNames) result.set(fieldName, groupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSchema({ messageName, definition, messages, enumNames }) {
|
||||||
|
const schema = {};
|
||||||
|
const usedIds = new Set();
|
||||||
|
const oneofByField = getOneofByField(definition);
|
||||||
|
|
||||||
|
for (const [fieldKey, fieldDefinition] of Object.entries(definition.fields)) {
|
||||||
|
const id = Number(fieldDefinition.id);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
throw new Error(`${messageName}.${fieldKey} 的字段编号无效`);
|
||||||
|
}
|
||||||
|
if (usedIds.has(id)) {
|
||||||
|
throw new Error(`${messageName} 存在重复字段编号 ${id}`);
|
||||||
|
}
|
||||||
|
usedIds.add(id);
|
||||||
|
|
||||||
|
const rule = fieldDefinition.rule;
|
||||||
|
if (rule && !["optional", "required", "repeated", "map"].includes(rule)) {
|
||||||
|
throw new Error(`${messageName}.${fieldKey} 使用了不支持的规则 ${rule}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldName = fieldDefinition.protoName || fieldKey;
|
||||||
|
const typeName = normalizeTypeName(fieldDefinition.type);
|
||||||
|
const keyTypeName = normalizeTypeName(
|
||||||
|
fieldDefinition.keyType ?? fieldDefinition.keytype
|
||||||
|
);
|
||||||
|
const isMap = rule === "map" || Boolean(keyTypeName);
|
||||||
|
|
||||||
|
if (isMap) {
|
||||||
|
if (!supportedMapKeyKinds.has(keyTypeName)) {
|
||||||
|
throw new Error(
|
||||||
|
`${messageName}.${fieldKey} 使用了不支持的 map key 类型 ${keyTypeName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const valueIsMessage = messages.has(typeName);
|
||||||
|
const valueKind = enumNames.has(typeName) ? "int32" : typeName;
|
||||||
|
if (!valueIsMessage && !supportedScalarKinds.has(valueKind)) {
|
||||||
|
throw new Error(
|
||||||
|
`${messageName}.${fieldKey} 使用了不支持的 map value 类型 ${typeName}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
schema[id] = valueIsMessage
|
||||||
|
? {
|
||||||
|
name: fieldName,
|
||||||
|
kind: "map",
|
||||||
|
keyKind: keyTypeName,
|
||||||
|
valueKind: "message",
|
||||||
|
valueType: typeName,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: fieldName,
|
||||||
|
kind: "map",
|
||||||
|
keyKind: keyTypeName,
|
||||||
|
valueKind,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMessage = messages.has(typeName);
|
||||||
|
const kind = enumNames.has(typeName) ? "int32" : typeName;
|
||||||
|
if (!isMessage && !supportedScalarKinds.has(kind)) {
|
||||||
|
throw new Error(`${messageName}.${fieldKey} 使用了不支持的类型 ${typeName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const repeated = rule === "repeated";
|
||||||
|
if (repeated && !isMessage && !["string", "bytes"].includes(kind)) {
|
||||||
|
throw new Error(
|
||||||
|
`${messageName}.${fieldKey} 是 packed scalar repeated,当前通用解码器尚不支持`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const field = isMessage
|
||||||
|
? { name: fieldName, kind: "message", type: typeName }
|
||||||
|
: { name: fieldName, kind };
|
||||||
|
if (repeated) field.repeated = true;
|
||||||
|
|
||||||
|
const oneof =
|
||||||
|
fieldDefinition.oneof ||
|
||||||
|
oneofByField.get(fieldKey) ||
|
||||||
|
compatibilityOneofs[messageName]?.[fieldName];
|
||||||
|
if (oneof) field.oneof = oneof;
|
||||||
|
schema[id] = field;
|
||||||
|
}
|
||||||
|
|
||||||
|
return schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPropertyKey(key) {
|
||||||
|
return /^(?:[A-Za-z_$][\w$]*|\d+)$/.test(key) ? key : JSON.stringify(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJsValue(value, depth = 0) {
|
||||||
|
if (!isRecord(value)) return JSON.stringify(value);
|
||||||
|
|
||||||
|
const entries = Object.entries(value);
|
||||||
|
if (entries.length === 0) return "{}";
|
||||||
|
|
||||||
|
const indent = " ".repeat(depth);
|
||||||
|
const primitiveEntries = entries.every(([, child]) => !isRecord(child));
|
||||||
|
if (primitiveEntries && value.kind !== "map") {
|
||||||
|
const singleLine = `{ ${entries
|
||||||
|
.map(([key, child]) => `${formatPropertyKey(key)}: ${JSON.stringify(child)}`)
|
||||||
|
.join(", ")} }`;
|
||||||
|
if (indent.length + singleLine.length <= 100) return singleLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childIndent = " ".repeat(depth + 1);
|
||||||
|
const lines = entries.map(
|
||||||
|
([key, child]) =>
|
||||||
|
`${childIndent}${formatPropertyKey(key)}: ${formatJsValue(child, depth + 1)},`
|
||||||
|
);
|
||||||
|
return `{\n${lines.join("\n")}\n${indent}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGeneratedSource(source) {
|
||||||
|
const descriptor = parseDescriptor(extractRootCreateArgument(source));
|
||||||
|
const namespace = findProtocolNamespace(descriptor);
|
||||||
|
if (!namespace) {
|
||||||
|
throw new Error("协议描述中未找到 ServerMessage 和 ClientMessage");
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = new Map();
|
||||||
|
const enums = new Map();
|
||||||
|
for (const [name, definition] of Object.entries(namespace.entries)) {
|
||||||
|
if (isRecord(definition?.fields)) {
|
||||||
|
messages.set(name, definition);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values = getEnumValues(definition);
|
||||||
|
if (values) enums.set(name, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverMessageType = enums.get("ServerMessageType");
|
||||||
|
const clientMessageType = enums.get("ClientMessageType");
|
||||||
|
if (!serverMessageType || !clientMessageType) {
|
||||||
|
throw new Error("协议描述缺少 ServerMessageType 或 ClientMessageType");
|
||||||
|
}
|
||||||
|
|
||||||
|
const enumNames = new Set(enums.keys());
|
||||||
|
const schemaByName = new Map();
|
||||||
|
for (const [messageName, definition] of messages) {
|
||||||
|
schemaByName.set(
|
||||||
|
messageName,
|
||||||
|
buildSchema({ messageName, definition, messages, enumNames })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachable = new Set();
|
||||||
|
function collectReachable(messageName) {
|
||||||
|
if (reachable.has(messageName)) return;
|
||||||
|
const schema = schemaByName.get(messageName);
|
||||||
|
if (!schema) throw new Error(`找不到消息定义 ${messageName}`);
|
||||||
|
reachable.add(messageName);
|
||||||
|
for (const field of Object.values(schema)) {
|
||||||
|
if (field.kind === "message") collectReachable(field.type);
|
||||||
|
if (field.kind === "map" && field.valueKind === "message") {
|
||||||
|
collectReachable(field.valueType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectReachable("ServerMessage");
|
||||||
|
|
||||||
|
const schemas = {};
|
||||||
|
for (const messageName of messages.keys()) {
|
||||||
|
if (reachable.has(messageName)) schemas[messageName] = schemaByName.get(messageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceHash = createHash("sha256").update(source).digest("hex").slice(0, 16);
|
||||||
|
const namespaceName = namespace.path.join(".") || "root";
|
||||||
|
const fieldCount = Object.values(schemas).reduce(
|
||||||
|
(total, schema) => total + Object.keys(schema).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const generatedBlock = [
|
||||||
|
generatedStartMarker,
|
||||||
|
"// 此区块由 scripts/generate-match-schema.mjs 自动生成,请勿手动修改。",
|
||||||
|
`// 来源:src/utils/match.min.js(sha256: ${sourceHash})`,
|
||||||
|
`// 协议命名空间:${namespaceName};消息数:${Object.keys(schemas).length};字段数:${fieldCount}`,
|
||||||
|
"",
|
||||||
|
`export const ServerMessageType = ${formatJsValue(serverMessageType)};`,
|
||||||
|
"",
|
||||||
|
`export const ClientMessageType = ${formatJsValue(clientMessageType)};`,
|
||||||
|
"",
|
||||||
|
`const SCHEMAS = ${formatJsValue(schemas)};`,
|
||||||
|
generatedEndMarker,
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatedBlock,
|
||||||
|
messageCount: Object.keys(schemas).length,
|
||||||
|
fieldCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const unknownArgs = args.filter((arg) => arg !== "--check");
|
||||||
|
if (unknownArgs.length > 0) {
|
||||||
|
throw new Error(`未知参数:${unknownArgs.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一换行符,避免 Windows 的 CRLF 让未变更的协议产生不同哈希。
|
||||||
|
const source = readFileSync(sourcePath, "utf8").replace(/\r\n?/g, "\n");
|
||||||
|
const { generatedBlock, messageCount, fieldCount } = createGeneratedSource(source);
|
||||||
|
const runtimeSource = readFileSync(runtimePath, "utf8");
|
||||||
|
const startIndex = runtimeSource.indexOf(generatedStartMarker);
|
||||||
|
const endMarkerIndex = runtimeSource.indexOf(generatedEndMarker);
|
||||||
|
if (startIndex < 0 || endMarkerIndex < startIndex) {
|
||||||
|
throw new Error("matchProtocol.js 缺少协议生成区块标记");
|
||||||
|
}
|
||||||
|
const endIndex = endMarkerIndex + generatedEndMarker.length;
|
||||||
|
const currentBlock = runtimeSource.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
if (args.includes("--check")) {
|
||||||
|
if (currentBlock !== generatedBlock) {
|
||||||
|
throw new Error(
|
||||||
|
"matchProtocol.js 的生成区块与 match.min.js 不同步,请运行 npm run proto:generate"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[match-schema] 同步校验通过:${messageCount} 个消息,${fieldCount} 个字段`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentBlock === generatedBlock) {
|
||||||
|
console.log(`[match-schema] 无需更新:${messageCount} 个消息,${fieldCount} 个字段`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRuntimeSource =
|
||||||
|
runtimeSource.slice(0, startIndex) + generatedBlock + runtimeSource.slice(endIndex);
|
||||||
|
writeFileSync(runtimePath, nextRuntimeSource, "utf8");
|
||||||
|
console.log(`[match-schema] 已生成:${messageCount} 个消息,${fieldCount} 个字段`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[match-schema] ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
+132
-3
@@ -7,9 +7,13 @@
|
|||||||
onHide
|
onHide
|
||||||
} from "@dcloudio/uni-app";
|
} from "@dcloudio/uni-app";
|
||||||
import websocket from "@/websocket";
|
import websocket from "@/websocket";
|
||||||
|
import matchWebsocket from "@/matchWebsocket";
|
||||||
import {
|
import {
|
||||||
getDeviceBatteryAPI
|
getDeviceBatteryAPI
|
||||||
} from "@/apis";
|
} from "@/apis";
|
||||||
|
import {
|
||||||
|
MESSAGETYPES
|
||||||
|
} from "@/constants";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import {
|
import {
|
||||||
storeToRefs
|
storeToRefs
|
||||||
@@ -17,12 +21,16 @@
|
|||||||
import audioManager from "./audioManager";
|
import audioManager from "./audioManager";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const {
|
const {
|
||||||
user
|
user,
|
||||||
|
device,
|
||||||
|
online
|
||||||
} = storeToRefs(store);
|
} = storeToRefs(store);
|
||||||
const {
|
const {
|
||||||
updateUser,
|
updateUser,
|
||||||
updateOnline,
|
updateOnline,
|
||||||
clearSessionState
|
showDeviceChargingDialog,
|
||||||
|
clearSessionState,
|
||||||
|
clearDevice
|
||||||
} = store;
|
} = store;
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -35,6 +43,9 @@
|
|||||||
websocket.createWebSocket(token, onShootWsMsg);
|
websocket.createWebSocket(token, onShootWsMsg);
|
||||||
}
|
}
|
||||||
if (!newVal) {
|
if (!newVal) {
|
||||||
|
matchWebsocket.closeMatchWebSocket({
|
||||||
|
reason: "logout"
|
||||||
|
});
|
||||||
websocket.closeWebSocket();
|
websocket.closeWebSocket();
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
@@ -60,24 +71,71 @@
|
|||||||
|
|
||||||
async function emitUpdateOnline() {
|
async function emitUpdateOnline() {
|
||||||
const data = await getDeviceBatteryAPI();
|
const data = await getDeviceBatteryAPI();
|
||||||
updateOnline(data.online);
|
const wasOnline = Boolean(online.value);
|
||||||
|
const nextOnline = Boolean(data.online);
|
||||||
|
updateOnline(nextOnline);
|
||||||
|
if (!device.value.deviceId || wasOnline === nextOnline) return;
|
||||||
|
audioManager.play(nextOnline ? "设备已连接" : "设备连接已断开");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeviceBindInvalid() {
|
||||||
|
clearDevice();
|
||||||
|
uni.setStorageSync("calibration", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeviceCharging() {
|
||||||
|
showDeviceChargingDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDeviceShoot() {
|
function onDeviceShoot() {
|
||||||
// audioManager.play("射箭声音")
|
// audioManager.play("射箭声音")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onNetworkStatusChange(status) {
|
||||||
|
matchWebsocket.handleMatchNetworkStatusChange(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectMatchServerFromMessage(content) {
|
||||||
|
const messages = Array.isArray(content) ? content : [content];
|
||||||
|
messages.forEach((message) => {
|
||||||
|
if (
|
||||||
|
message?.constructor ===
|
||||||
|
MESSAGETYPES.ShootSyncBattleCreateMatchLinkID
|
||||||
|
) {
|
||||||
|
matchWebsocket.connectMatchWebSocketFromNotice(
|
||||||
|
message,
|
||||||
|
user.value.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onShootWsMsg(content) {
|
function onShootWsMsg(content) {
|
||||||
if(content.type === 'shoot-trigger'){
|
if(content.type === 'shoot-trigger'){
|
||||||
onDeviceShoot()
|
onDeviceShoot()
|
||||||
}
|
}
|
||||||
|
connectMatchServerFromMessage(content);
|
||||||
uni.$emit("socket-inbox", content);
|
uni.$emit("socket-inbox", content);
|
||||||
}
|
}
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
|
void audioManager.warmButton();
|
||||||
uni.$on("update-user", emitUpdateUser);
|
uni.$on("update-user", emitUpdateUser);
|
||||||
uni.$on("update-online", emitUpdateOnline);
|
uni.$on("update-online", emitUpdateOnline);
|
||||||
uni.$on("session-kicked-out", onSessionKickedOut);
|
uni.$on("session-kicked-out", onSessionKickedOut);
|
||||||
|
uni.$on("device-bind-invalid", onDeviceBindInvalid);
|
||||||
|
uni.$on("device-charging", onDeviceCharging);
|
||||||
|
if (typeof uni.offNetworkStatusChange === "function") {
|
||||||
|
uni.offNetworkStatusChange(onNetworkStatusChange);
|
||||||
|
}
|
||||||
|
if (typeof uni.onNetworkStatusChange === "function") {
|
||||||
|
uni.onNetworkStatusChange(onNetworkStatusChange);
|
||||||
|
}
|
||||||
|
if (typeof uni.getNetworkType === "function") {
|
||||||
|
uni.getNetworkType({
|
||||||
|
success: onNetworkStatusChange
|
||||||
|
});
|
||||||
|
}
|
||||||
const token = uni.getStorageSync(
|
const token = uni.getStorageSync(
|
||||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||||
);
|
);
|
||||||
@@ -91,6 +149,14 @@
|
|||||||
uni.$off("update-user", emitUpdateUser);
|
uni.$off("update-user", emitUpdateUser);
|
||||||
uni.$off("update-online", emitUpdateOnline);
|
uni.$off("update-online", emitUpdateOnline);
|
||||||
uni.$off("session-kicked-out", onSessionKickedOut);
|
uni.$off("session-kicked-out", onSessionKickedOut);
|
||||||
|
uni.$off("device-bind-invalid", onDeviceBindInvalid);
|
||||||
|
uni.$off("device-charging", onDeviceCharging);
|
||||||
|
if (typeof uni.offNetworkStatusChange === "function") {
|
||||||
|
uni.offNetworkStatusChange(onNetworkStatusChange);
|
||||||
|
}
|
||||||
|
matchWebsocket.closeMatchWebSocket({
|
||||||
|
reason: "app-hide"
|
||||||
|
});
|
||||||
websocket.closeWebSocket();
|
websocket.closeWebSocket();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -250,6 +316,69 @@
|
|||||||
text-overflow: ellipsis;
|
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 {
|
.modal {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+231
-16
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeBattleApiResult } from "@/utils/matchAdapter";
|
||||||
|
|
||||||
let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版
|
let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -23,7 +25,10 @@ try {
|
|||||||
console.error("获取环境信息失败,使用默认正式环境", e);
|
console.error("获取环境信息失败,使用默认正式环境", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
function request(method, url, data = {}) {
|
const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot");
|
||||||
|
const API_ROOT_URL = BASE_URL.replace(/\/api\/shoot$/, "");
|
||||||
|
// 统一处理业务接口请求,包含登录态、业务错误和特定接口空响应兼容。
|
||||||
|
function request(method, url, data = {}, baseUrl = BASE_URL, successCodes = [0]) {
|
||||||
const token = uni.getStorageSync(
|
const token = uni.getStorageSync(
|
||||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||||
);
|
);
|
||||||
@@ -31,16 +36,25 @@ function request(method, url, data = {}) {
|
|||||||
if (token) header.Authorization = `Bearer ${token || ""}`;
|
if (token) header.Authorization = `Bearer ${token || ""}`;
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
uni.request({
|
uni.request({
|
||||||
url: `${BASE_URL}${url}`,
|
url: `${baseUrl}${url}`,
|
||||||
method,
|
method,
|
||||||
header,
|
header,
|
||||||
data,
|
data,
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
|
const acceptsEmptyResponse = [
|
||||||
|
"/user/hardwareBox/connectWifi",
|
||||||
|
"/user/device/unbindByQrcodeId",
|
||||||
|
].includes(url);
|
||||||
|
if (acceptsEmptyResponse && res.statusCode === 200 && res.data && Object.keys(res.data).length === 0) {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
const {code, data, message} = res.data;
|
const {code, data, message} = res.data;
|
||||||
if (code === 0) resolve(data);
|
if (successCodes.includes(code)) resolve(data);
|
||||||
else if (message) {
|
else if (message) {
|
||||||
|
const error = {code, data, message};
|
||||||
if (message.indexOf("登录身份已失效") !== -1) {
|
if (message.indexOf("登录身份已失效") !== -1) {
|
||||||
console.log('1111111111111111111,token失效')
|
console.log('1111111111111111111,token失效')
|
||||||
uni.removeStorageSync(
|
uni.removeStorageSync(
|
||||||
@@ -50,6 +64,10 @@ function request(method, url, data = {}) {
|
|||||||
reject({ type: "AUTH_INVALID", message });
|
reject({ type: "AUTH_INVALID", message });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (message.indexOf("已达上限") !== -1) {
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message === "ROOM_FULL") {
|
if (message === "ROOM_FULL") {
|
||||||
resolve({full: true});
|
resolve({full: true});
|
||||||
return;
|
return;
|
||||||
@@ -70,6 +88,15 @@ function request(method, url, data = {}) {
|
|||||||
resolve({binded: true});
|
resolve({binded: true});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (message === "BIND_FAILD") {
|
||||||
|
uni.$emit("device-bind-invalid");
|
||||||
|
uni.showToast({
|
||||||
|
title: "设备绑定状态已失效,请重新绑定",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
reject({type: "DEVICE_BIND_INVALID", message});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (message === "ERROR_ORDER_UNPAY") {
|
if (message === "ERROR_ORDER_UNPAY") {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: "当前有未支付订单",
|
title: "当前有未支付订单",
|
||||||
@@ -88,8 +115,10 @@ function request(method, url, data = {}) {
|
|||||||
title: message,
|
title: message,
|
||||||
icon: "none",
|
icon: "none",
|
||||||
});
|
});
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
reject("");
|
reject({code, data, message});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
@@ -158,8 +187,23 @@ export const getAppConfig = () => {
|
|||||||
return request("GET", "/index/appConfig");
|
return request("GET", "/index/appConfig");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getHomeData = (seasonId) => {
|
export const getDailyCountAPI = () => {
|
||||||
return request("GET", `/user/myHome?seasonId=${seasonId}`);
|
return request("GET", "/index/dailyCount", {}, ADDONS_BASE_URL);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHomeData = async (seasonId) => {
|
||||||
|
const data = await request("GET", `/user/myHome?seasonId=${seasonId}`);
|
||||||
|
if (!data?.user) return data;
|
||||||
|
|
||||||
|
// 段位信息由 myHome 接口直接返回,统一并入用户数据供各页面展示。
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
user: {
|
||||||
|
...data.user,
|
||||||
|
rankIcon: data.rankIcon,
|
||||||
|
rankName: data.rankName,
|
||||||
|
},
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProvinceData = () => {
|
export const getProvinceData = () => {
|
||||||
@@ -203,6 +247,12 @@ export const checkUserBindAPI = async (code) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const tempBindOrgAPI = (scene) => {
|
||||||
|
return request("POST", "/user/org/device/tempBind", {
|
||||||
|
scene,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const bindDeviceAPI = (device) => {
|
export const bindDeviceAPI = (device) => {
|
||||||
return request("POST", "/user/device/bindDevice", {
|
return request("POST", "/user/device/bindDevice", {
|
||||||
device,
|
device,
|
||||||
@@ -221,6 +271,11 @@ export const unbindDeviceAPI = (deviceId) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 测试环境根据设备二维码编号解绑设备,不校验设备归属。
|
||||||
|
export const unbindDeviceByQrcodeIdAPI = (id) => {
|
||||||
|
return request("POST", "/user/device/unbindByQrcodeId", {id});
|
||||||
|
};
|
||||||
|
|
||||||
export const getMyDevicesAPI = () => {
|
export const getMyDevicesAPI = () => {
|
||||||
// "/user/device/getBinding?deviceId=9ZF9oVXs"
|
// "/user/device/getBinding?deviceId=9ZF9oVXs"
|
||||||
return request("GET", "/user/device/getBindings");
|
return request("GET", "/user/device/getBindings");
|
||||||
@@ -234,18 +289,36 @@ export const createPractiseAPI = (arrows, time, target) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const startPractiseAPI = () => {
|
export const createPractiseV2API = (trainingType, difficultyLevel) => {
|
||||||
return request("POST", "/user/practice/begin");
|
return request("POST", "/user/practice/create/v2", {
|
||||||
|
trainingType,
|
||||||
|
difficultyLevel,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const endPractiseAPI = () => {
|
export const getCurrentPractiseAPI = () => {
|
||||||
return request("POST", "/user/practice/stop");
|
return request("GET", "/user/practice/current");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const startPractiseAPI = (id) => {
|
||||||
|
return request("POST", "/user/practice/begin", { id });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const endPractiseAPI = (id) => {
|
||||||
|
return request("POST", "/user/practice/stop", { id });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getPractiseAPI = async (id) => {
|
export const getPractiseAPI = async (id) => {
|
||||||
return request("GET", `/user/practice/get?id=${id}`);
|
return request("GET", `/user/practice/get?id=${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPractiseDetailAPI = async (id) => {
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
`/user/practice/detail?id=${encodeURIComponent(id)}`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const createRoomAPI = (gameType, teamSize, targetType) => {
|
export const createRoomAPI = (gameType, teamSize, targetType) => {
|
||||||
return request("POST", "/user/createroom", {
|
return request("POST", "/user/createroom", {
|
||||||
gameType,
|
gameType,
|
||||||
@@ -279,12 +352,12 @@ 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) => {
|
export const getPractiseResultListAPI = async (page = 1, pageSize = 15) => {
|
||||||
const reuslt = await request(
|
const result = await request(
|
||||||
"GET",
|
"GET",
|
||||||
`/user/practice/list?page=${page}&page_size=${page_size}`
|
`/user/practice/mylist?page=${page}&pageSize=${pageSize}&status=0`
|
||||||
);
|
);
|
||||||
return reuslt.list;
|
return Array.isArray(result?.list) ? result.list : [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const matchGameAPI = (match, gameType, teamSize) => {
|
export const matchGameAPI = (match, gameType, teamSize) => {
|
||||||
@@ -303,9 +376,11 @@ export const readyGameAPI = (battleId) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const simulShootAPI = (device_id, x, y) => {
|
export const simulShootAPI = (device_id, x, y, targetType = 40) => {
|
||||||
const data = {
|
const data = {
|
||||||
device_id,
|
device_id,
|
||||||
|
// 模拟射箭仅支持 20cm、40cm 靶纸,未传或传入无效值时默认使用 40cm。
|
||||||
|
targetType: Number(targetType) === 20 ? 20 : 40,
|
||||||
};
|
};
|
||||||
if (x !== undefined && y !== undefined) {
|
if (x !== undefined && y !== undefined) {
|
||||||
data.x = x;
|
data.x = x;
|
||||||
@@ -333,9 +408,23 @@ export const createOrderAPI = (vipId) => {
|
|||||||
quanity: 1,
|
quanity: 1,
|
||||||
tradeType: "mini",
|
tradeType: "mini",
|
||||||
payType: "wxpay",
|
payType: "wxpay",
|
||||||
|
returnUrl: "",
|
||||||
|
remark: "",
|
||||||
|
mockTest: false,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const virtualPayOrderAPI = (vipId = 0, code = "") => {
|
||||||
|
return request("POST", "/user/virtualPay/createOrder", {
|
||||||
|
vipId,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderDetailAPI = (orderId) => {
|
||||||
|
return request("GET", `/user/order/detail?orderId=${encodeURIComponent(orderId)}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const payOrderAPI = (id) => {
|
export const payOrderAPI = (id) => {
|
||||||
return request("POST", "/user/order/pay", {
|
return request("POST", "/user/order/pay", {
|
||||||
id,
|
id,
|
||||||
@@ -410,6 +499,15 @@ export const getPractiseDataAPI = async () => {
|
|||||||
return request("GET", "/user/practice/statistics");
|
return request("GET", "/user/practice/statistics");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPersonalTrainingAPI = async () => {
|
||||||
|
return request("GET", "/personal/training");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTrainingDifficultyListAPI = async (type) => {
|
||||||
|
const query = type ? `?type=${encodeURIComponent(type)}` : "";
|
||||||
|
return request("GET", `/training/difficulty/list${query}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const getBattleDataAPI = async () => {
|
export const getBattleDataAPI = async () => {
|
||||||
return request("GET", "/user/fight/statistics");
|
return request("GET", "/user/fight/statistics");
|
||||||
};
|
};
|
||||||
@@ -440,6 +538,25 @@ export const laserAimAPI = async () => {
|
|||||||
return request("POST", "/user/device/laserAim");
|
return request("POST", "/user/device/laserAim");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 调瞄续期只负责发送请求,不等待或处理响应,避免阻塞下一次续期。
|
||||||
|
export const aimRenewAPI = () => {
|
||||||
|
const token = uni.getStorageSync(
|
||||||
|
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||||
|
);
|
||||||
|
const header = {};
|
||||||
|
if (token) header.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
uni.request({
|
||||||
|
url: `${BASE_URL}/user/device/aimRenew`,
|
||||||
|
method: "POST",
|
||||||
|
header,
|
||||||
|
data: {},
|
||||||
|
timeout: 10000,
|
||||||
|
success: () => {},
|
||||||
|
fail: () => {},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const laserCloseAPI = async () => {
|
export const laserCloseAPI = async () => {
|
||||||
return request("POST", "/user/device/closeAim");
|
return request("POST", "/user/device/closeAim");
|
||||||
};
|
};
|
||||||
@@ -448,6 +565,26 @@ export const getDeviceBatteryAPI = async () => {
|
|||||||
return request("GET", "/user/device/battery");
|
return request("GET", "/user/device/battery");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 设备连接指定 WiFi,只下发 WiFi 凭证,不触发 OTA 升级。
|
||||||
|
export const connectDeviceWifiAPI = async (ssid, password) => {
|
||||||
|
return request("POST", "/user/hardwareBox/connectWifi", {ssid, password});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取硬件盒子版本信息,用于判断当前设备是否需要 OTA 升级。
|
||||||
|
export const getHardwareBoxVersionAPI = async () => {
|
||||||
|
return request("GET", "/user/hardwareBox/version");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送硬件盒子 OTA 更新指令,服务端会返回后续轮询使用的任务 ID。
|
||||||
|
export const sendHardwareBoxUpdateAPI = async (data) => {
|
||||||
|
return request("POST", "/user/hardwareBox/sendUpdate", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据任务 ID 获取硬件盒子 OTA 更新状态。
|
||||||
|
export const getHardwareBoxTaskStatusAPI = async (taskId) => {
|
||||||
|
return request("GET", `/user/hardwareBox/taskStatus?taskId=${taskId}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const addNoteAPI = async (id, remark) => {
|
export const addNoteAPI = async (id, remark) => {
|
||||||
return request("POST", "/user/score/sheet/remark", {id, remark});
|
return request("POST", "/user/score/sheet/remark", {id, remark});
|
||||||
};
|
};
|
||||||
@@ -460,6 +597,10 @@ export const getPhoneNumberAPI = (data) => {
|
|||||||
return request("POST", "/index/getPhone", data);
|
return request("POST", "/index/getPhone", data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPhoneNumberAPIv2 = (data) => {
|
||||||
|
return request("POST", "/index/getPhone/v2", data);
|
||||||
|
};
|
||||||
|
|
||||||
export const getPointBookRankListAPI = (page = 1) => {
|
export const getPointBookRankListAPI = (page = 1) => {
|
||||||
return request(
|
return request(
|
||||||
"GET",
|
"GET",
|
||||||
@@ -488,9 +629,10 @@ export const getReadyAPI = (roomId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getBattleAPI = async (battleId) => {
|
export const getBattleAPI = async (battleId) => {
|
||||||
return request("POST", "/user/match/info", {
|
const result = await request("POST", "/user/match/info", {
|
||||||
id: battleId,
|
id: battleId,
|
||||||
});
|
});
|
||||||
|
return normalizeBattleApiResult(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const kickPlayerAPI = (number, userId) => {
|
export const kickPlayerAPI = (number, userId) => {
|
||||||
@@ -559,3 +701,76 @@ export const getMyTenRingRank = (seasonId) => {
|
|||||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||||
return request("GET", "/index/myTenRingRank", data);
|
return request("GET", "/index/myTenRingRank", data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取当前用户的金币统计,可按门店查询。
|
||||||
|
export const getMyGoldAPI = (storeId) => {
|
||||||
|
const data = {};
|
||||||
|
if (storeId !== undefined && storeId !== null) data.storeId = storeId;
|
||||||
|
return request("GET", "/index/gold/my", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页获取当前用户的金币流水,type:1=获得,2=兑换。
|
||||||
|
export const getGoldLogAPI = ({page = 1, pageSize = 20, type, storeId} = {}) => {
|
||||||
|
const data = {page, pageSize};
|
||||||
|
if (type !== undefined && type !== null) data.type = type;
|
||||||
|
if (storeId !== undefined && storeId !== null) data.storeId = storeId;
|
||||||
|
return request("GET", "/index/gold/log", data);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 前台礼品接口位于站点根路径,并使用 code=200 表示成功。
|
||||||
|
export const getGiftListAPI = ({
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
sort = "coin_desc",
|
||||||
|
storeId,
|
||||||
|
} = {}) => {
|
||||||
|
const data = {page, page_size: pageSize, sort};
|
||||||
|
if (storeId !== undefined && storeId !== null && storeId !== "") {
|
||||||
|
data.store_id = storeId;
|
||||||
|
}
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
"/gin/api/v1/gift/list",
|
||||||
|
data,
|
||||||
|
API_ROOT_URL,
|
||||||
|
[0, 200]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getGiftDetailAPI = (id) => {
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
`/gin/api/v1/gift/${id}`,
|
||||||
|
{},
|
||||||
|
API_ROOT_URL,
|
||||||
|
[0, 200]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 根据用户定位分页获取附近门店。
|
||||||
|
export const getNearbyStoresAPI = ({
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
radius = 65535,
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
} = {}) => {
|
||||||
|
return request("GET", "/store/nearby", {
|
||||||
|
longitude,
|
||||||
|
latitude,
|
||||||
|
radius,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页获取指定门店的公开金币规则。
|
||||||
|
export const getStoreGoldRuleListAPI = ({storeId, page = 1, pageSize = 20} = {}) => {
|
||||||
|
return request(
|
||||||
|
"GET",
|
||||||
|
`/gin/api/v1/super-admin/gold-rule/store/${storeId}/list`,
|
||||||
|
{page, page_size: pageSize},
|
||||||
|
API_ROOT_URL,
|
||||||
|
[0, 200]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
+717
-216
File diff suppressed because it is too large
Load Diff
+44
-19
@@ -456,23 +456,29 @@ export const generateShareImage = async (canvasId, data) => {
|
|||||||
// 2D 即时绘制,无需 ctx.draw()
|
// 2D 即时绘制,无需 ctx.draw()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("generateShareImage 绘制失败:", e);
|
console.error("generateShareImage 绘制失败:", e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 顶部导入与工具方法
|
// 顶部导入与工具方法
|
||||||
async function getCanvas2DContext(canvasId, targetWidth, targetHeight) {
|
async function getCanvas2DContext(canvasId, targetWidth, targetHeight) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve, reject) => {
|
||||||
const query = uni.createSelectorQuery();
|
const query = uni.createSelectorQuery();
|
||||||
query
|
query
|
||||||
.select(`#${canvasId}`)
|
.select(`#${canvasId}`)
|
||||||
.fields({ node: true, size: true })
|
.fields({ node: true, size: true })
|
||||||
.exec((res) => {
|
.exec((res) => {
|
||||||
const { node: canvas } = res[0] || {};
|
const canvasInfo = res && res[0];
|
||||||
|
const { node: canvas } = canvasInfo || {};
|
||||||
|
if (!canvas || typeof canvas.getContext !== "function") {
|
||||||
|
reject(new Error(`canvas ${canvasId} not found`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
const dpr = uni.getSystemInfoSync().pixelRatio || 1;
|
const dpr = uni.getSystemInfoSync().pixelRatio || 1;
|
||||||
|
|
||||||
const w = targetWidth || res[0].width;
|
const w = targetWidth || canvasInfo.width;
|
||||||
const h = targetHeight || res[0].height;
|
const h = targetHeight || canvasInfo.height;
|
||||||
|
|
||||||
canvas.width = w * dpr;
|
canvas.width = w * dpr;
|
||||||
canvas.height = h * dpr;
|
canvas.height = h * dpr;
|
||||||
@@ -561,6 +567,7 @@ export const sharePointData = async (canvasId, data) => {
|
|||||||
// 2D 即时绘制,无需 ctx.draw()
|
// 2D 即时绘制,无需 ctx.draw()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("generateShareImage 绘制失败:", e);
|
console.error("generateShareImage 绘制失败:", e);
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -628,7 +635,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
"../static/score-bg.png",
|
"/static/score-bg.png",
|
||||||
16 + (i % 9) * 30,
|
16 + (i % 9) * 30,
|
||||||
290 + Math.ceil((i + 1) / 9) * 30,
|
290 + Math.ceil((i + 1) / 9) * 30,
|
||||||
27,
|
27,
|
||||||
@@ -650,7 +657,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
|
|||||||
ctx.drawImage(bgImg, 24 + rowIndex * 42, i > 5 ? 362 : 320, 38, 38);
|
ctx.drawImage(bgImg, 24 + rowIndex * 42, i > 5 ? 362 : 320, 38, 38);
|
||||||
} else {
|
} else {
|
||||||
ctx.drawImage(
|
ctx.drawImage(
|
||||||
"../static/score-bg.png",
|
"/static/score-bg.png",
|
||||||
24 + rowIndex * 42,
|
24 + rowIndex * 42,
|
||||||
i > 5 ? 362 : 320,
|
i > 5 ? 362 : 320,
|
||||||
38,
|
38,
|
||||||
@@ -699,22 +706,39 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
|||||||
);
|
);
|
||||||
const bgImg = await loadCanvasImage(canvas, bgImgSrc);
|
const bgImg = await loadCanvasImage(canvas, bgImgSrc);
|
||||||
|
|
||||||
const avatarImgPromise = loadImage(user.avatar).then((path) =>
|
// 头像与段位框属于装饰图片,缺失时使用兜底或跳过,避免阻断分享。
|
||||||
loadCanvasImage(canvas, path)
|
const loadProfileImage = async (src, fallbackSrc = "", label = "") => {
|
||||||
);
|
const normalizedSrc =
|
||||||
const lvlImgPromise = loadImage(user.lvlImage).then((path) =>
|
typeof src === "string" && src.startsWith("../static/")
|
||||||
loadCanvasImage(canvas, path)
|
? src.slice(2)
|
||||||
);
|
: src;
|
||||||
|
if (normalizedSrc) {
|
||||||
|
try {
|
||||||
|
const path = await loadImage(normalizedSrc);
|
||||||
|
return await loadCanvasImage(canvas, path);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`share ${label} image load failed`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackSrc ? loadCanvasImage(canvas, fallbackSrc) : null;
|
||||||
|
};
|
||||||
|
|
||||||
let titleImageSrc = "../static/first-try-title.png";
|
const avatarImgPromise = loadProfileImage(
|
||||||
|
user?.avatar,
|
||||||
|
"/static/user-icon.png",
|
||||||
|
"avatar"
|
||||||
|
);
|
||||||
|
const lvlImgPromise = loadProfileImage(user?.lvlImage, "", "level");
|
||||||
|
|
||||||
|
let titleImageSrc = "/static/first-try-title.png";
|
||||||
if (type == 2) {
|
if (type == 2) {
|
||||||
titleImageSrc = "../static/practise-one-title.png";
|
titleImageSrc = "/static/practise-one-title.png";
|
||||||
} else if (type == 3) {
|
} else if (type == 3) {
|
||||||
titleImageSrc = "../static/practise-two-title.png";
|
titleImageSrc = "/static/practise-two-title.png";
|
||||||
}
|
}
|
||||||
const titleImgPromise = loadCanvasImage(canvas, titleImageSrc);
|
const titleImgPromise = loadCanvasImage(canvas, titleImageSrc);
|
||||||
const scoreBgImgPromise = loadCanvasImage(canvas, "../static/score-bg.png");
|
const scoreBgImgPromise = loadCanvasImage(canvas, "/static/score-bg.png");
|
||||||
const qrCodeImgPromise = loadCanvasImage(canvas, "../static/qr-code.png");
|
const qrCodeImgPromise = loadCanvasImage(canvas, "/static/qr-code.png");
|
||||||
|
|
||||||
const [avatarImg, lvlImg, titleImg, scoreBgImg, qrCodeImg] =
|
const [avatarImg, lvlImg, titleImg, scoreBgImg, qrCodeImg] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -731,8 +755,8 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
|||||||
|
|
||||||
ctx.drawImage(bgImg, 0, 0, width, height);
|
ctx.drawImage(bgImg, 0, 0, width, height);
|
||||||
|
|
||||||
drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20);
|
if (avatarImg) drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20);
|
||||||
ctx.drawImage(lvlImg, 12, 15, 42, 42);
|
if (lvlImg) ctx.drawImage(lvlImg, 12, 15, 42, 42);
|
||||||
|
|
||||||
renderText(ctx, user.nickName, 13, "#fff", 58, 34);
|
renderText(ctx, user.nickName, 13, "#fff", 58, 34);
|
||||||
renderRankTitle(ctx, user.lvlName);
|
renderRankTitle(ctx, user.lvlName);
|
||||||
@@ -778,6 +802,7 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
|||||||
// 2D 模式下无需 ctx.draw()
|
// 2D 模式下无需 ctx.draw()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,36 @@ const props = defineProps({
|
|||||||
src="https://static.shelingxingqiu.com/shootmini/static/rank/rank-bg.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/rank/rank-bg.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 7"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg6.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 8"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg7.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 9"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg8.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="bg-image"
|
||||||
|
v-if="type === 11"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/app-bg9.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 class="bg-overlay" v-if="type === 0"></view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ image: "../static/tab-vip.png" },
|
{ image: "../static/tab-vip.png" },
|
||||||
{ image: "../static/tab-point-book.png" },
|
{ image: "https://static.shelingxingqiu.com/shootmini/static/tab-point-book.png" },
|
||||||
{ image: "../static/tab-mall.png" },
|
{ image: "https://static.shelingxingqiu.com/shootmini/static/tab-mall.png" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function handleTabClick(index) {
|
function handleTabClick(index) {
|
||||||
if (index === 0) {
|
if (index === 0) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: "/pages/be-vip",
|
url: "/pages/member/be-vip",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (index === 1) {
|
if (index === 1) {
|
||||||
@@ -26,7 +26,7 @@ function handleTabClick(index) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="footer">
|
<view class="footer">
|
||||||
<image class="footer-bg" src="../static/tab-bg.png" mode="widthFix" />
|
<image class="footer-bg" src="https://static.shelingxingqiu.com/shootmini/static/tab-bg.png" mode="widthFix" />
|
||||||
<view
|
<view
|
||||||
v-for="(tab, index) in tabs"
|
v-for="(tab, index) in tabs"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -43,9 +43,13 @@ function handleTabClick(index) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.footer {
|
.footer {
|
||||||
height: 120px;
|
/* height: 120px; */
|
||||||
|
height: 190rpx;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
position: relative;
|
/* position: relative; */
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+25
-12
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -26,12 +26,33 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 45,
|
default: 45,
|
||||||
},
|
},
|
||||||
|
sizeUnit: {
|
||||||
|
type: String,
|
||||||
|
default: "px",
|
||||||
|
},
|
||||||
|
imageMode: {
|
||||||
|
type: String,
|
||||||
|
default: "widthFix",
|
||||||
|
},
|
||||||
borderColor: {
|
borderColor: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const avatarFrame = ref("");
|
const avatarFrame = ref("");
|
||||||
|
const sizeValue = computed(() => `${Number(props.size)}${props.sizeUnit}`);
|
||||||
|
const frameSizeValue = computed(() => `${Number(props.size) + 10}${props.sizeUnit}`);
|
||||||
|
const avatarImageStyle = computed(() => ({
|
||||||
|
width: sizeValue.value,
|
||||||
|
height: sizeValue.value,
|
||||||
|
minHeight: sizeValue.value,
|
||||||
|
borderColor: props.borderColor || "#fff",
|
||||||
|
}));
|
||||||
|
const avatarFrameStyle = computed(() => ({
|
||||||
|
width: frameSizeValue.value,
|
||||||
|
height: frameSizeValue.value,
|
||||||
|
}));
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [config.value, props.rankLvl],
|
() => [config.value, props.rankLvl],
|
||||||
() => {
|
() => {
|
||||||
@@ -51,10 +72,7 @@ watch(
|
|||||||
v-if="avatarFrame"
|
v-if="avatarFrame"
|
||||||
:src="avatarFrame"
|
:src="avatarFrame"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
:style="{
|
:style="avatarFrameStyle"
|
||||||
width: Number(size) + 10 + 'px',
|
|
||||||
height: Number(size) + 10 + 'px',
|
|
||||||
}"
|
|
||||||
class="avatar-frame"
|
class="avatar-frame"
|
||||||
/>
|
/>
|
||||||
<image
|
<image
|
||||||
@@ -78,13 +96,8 @@ watch(
|
|||||||
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
||||||
<image
|
<image
|
||||||
:src="src || '../static/user-icon.png'"
|
:src="src || '../static/user-icon.png'"
|
||||||
mode="widthFix"
|
:mode="imageMode"
|
||||||
:style="{
|
:style="avatarImageStyle"
|
||||||
width: size + 'px',
|
|
||||||
height: size + 'px',
|
|
||||||
minHeight: size + 'px',
|
|
||||||
borderColor: borderColor || '#fff',
|
|
||||||
}"
|
|
||||||
class="avatar-image"
|
class="avatar-image"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { onShow } from "@dcloudio/uni-app";
|
|||||||
|
|
||||||
import { getBattleAPI, getUserGameState } from "@/apis";
|
import { getBattleAPI, getUserGameState } from "@/apis";
|
||||||
import { debounce } from "@/util";
|
import { debounce } from "@/util";
|
||||||
|
import { returnToBattle } from "@/utils/matchReturn";
|
||||||
|
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
@@ -18,12 +19,14 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const navigating = ref(false);
|
||||||
|
|
||||||
/** 统一获取当前环境 token,用于守卫:无有效 token 时不发起接口请求 */
|
/** 统一获取当前环境 token,用于守卫:无有效 token 时不发起接口请求 */
|
||||||
const getToken = () =>
|
const getToken = () =>
|
||||||
uni.getStorageSync(`${uni.getAccountInfoSync().miniProgram.envVersion}_token`);
|
uni.getStorageSync(`${uni.getAccountInfoSync().miniProgram.envVersion}_token`);
|
||||||
|
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
|
navigating.value = false;
|
||||||
if (user.value.id && getToken()) {
|
if (user.value.id && getToken()) {
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
const state = await getUserGameState();
|
const state = await getUserGameState();
|
||||||
@@ -45,28 +48,30 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 () => {
|
const onClick = debounce(async () => {
|
||||||
if (loading.value) return;
|
if (loading.value || navigating.value) return;
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const result = await getBattleAPI();
|
const result = await getBattleAPI();
|
||||||
if (result && result.matchId) {
|
if (result && result.matchId) {
|
||||||
await uni.$checkAudio();
|
await returnToBattle(result, navigateOnce);
|
||||||
if (result.mode <= 3) {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/team-battle/index?battleId=${result.matchId}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/melee-battle?battleId=${result.matchId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (game.value.roomID) {
|
if (game.value.roomID) {
|
||||||
uni.navigateTo({
|
await navigateOnce("/pages/battle-room?roomNumber=" + game.value.roomID);
|
||||||
url: "/pages/battle-room?roomNumber=" + game.value.roomID,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
updateGame(false, "");
|
updateGame(false, "");
|
||||||
}
|
}
|
||||||
@@ -91,9 +96,9 @@ onBeforeUnmount(() => {
|
|||||||
class="back-to-game"
|
class="back-to-game"
|
||||||
@click="onClick"
|
@click="onClick"
|
||||||
>
|
>
|
||||||
<image src="../static/back-to-game-bg.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/back-to-game-bg.png" mode="widthFix" />
|
||||||
<block v-if="game.inBattle">
|
<block v-if="game.inBattle">
|
||||||
<image src="../static/pk-icon.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/pk-icon.png" mode="widthFix" />
|
||||||
<text>返回进行中的对局</text>
|
<text>返回进行中的对局</text>
|
||||||
</block>
|
</block>
|
||||||
<block v-else-if="game.roomID">
|
<block v-else-if="game.roomID">
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const normalRounds = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<view class="guide-row">
|
<view class="guide-row">
|
||||||
<image src="../static/shooter.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/shooter.png" mode="widthFix" />
|
||||||
<view
|
<view
|
||||||
:style="{
|
:style="{
|
||||||
marginBottom: '10px',
|
marginBottom: '10px',
|
||||||
@@ -42,7 +42,7 @@ const normalRounds = computed(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view>
|
<view>
|
||||||
<image src="../static/battle-header-melee.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/battle-header-melee.png" mode="widthFix" />
|
||||||
<text>蓝队({{ bluePoints }})</text>
|
<text>蓝队({{ bluePoints }})</text>
|
||||||
<text>红队({{ redPoints }})</text>
|
<text>红队({{ redPoints }})</text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -27,19 +27,27 @@ defineProps({
|
|||||||
default: true,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="container" :style="{ paddingTop: showHeader ? '5px' : '0' }">
|
<view class="container">
|
||||||
<image
|
<image
|
||||||
v-if="showHeader"
|
v-if="showHeader"
|
||||||
:src="`../static/battle-header${players.length ? '-melee' : ''}.png`"
|
:src="`https://static.shelingxingqiu.com/shootmini/static/battle-header${players.length ? '-melee' : ''}.png`"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<view
|
<view
|
||||||
v-if="!players.length && blueTeam.length && redTeam.length"
|
v-if="!players.length && blueTeam.length && redTeam.length"
|
||||||
class="players"
|
class="players"
|
||||||
:style="{ paddingTop: showHeader ? '15px' : '0' }"
|
:style="{ paddingTop: showHeader ? '48rpx' : '0' }"
|
||||||
>
|
>
|
||||||
<view>
|
<view>
|
||||||
<view
|
<view
|
||||||
@@ -51,7 +59,16 @@ defineProps({
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
<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>
|
</view>
|
||||||
<image
|
<image
|
||||||
v-if="winner === 1"
|
v-if="winner === 1"
|
||||||
@@ -70,7 +87,16 @@ defineProps({
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
<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>
|
</view>
|
||||||
<image
|
<image
|
||||||
v-if="winner === 2"
|
v-if="winner === 2"
|
||||||
@@ -87,6 +113,7 @@ defineProps({
|
|||||||
v-if="players.length"
|
v-if="players.length"
|
||||||
class="players-melee"
|
class="players-melee"
|
||||||
scroll-x
|
scroll-x
|
||||||
|
:show-scrollbar="false"
|
||||||
@touchmove="(e) => players.length > 5 && e.stopPropagation()"
|
@touchmove="(e) => players.length > 5 && e.stopPropagation()"
|
||||||
:style="{ paddingTop: showHeader ? '15px' : '0' }"
|
:style="{ paddingTop: showHeader ? '15px' : '0' }"
|
||||||
>
|
>
|
||||||
@@ -105,7 +132,16 @@ defineProps({
|
|||||||
:size="40"
|
:size="40"
|
||||||
:rank="showRank ? index + 1 : 0"
|
:rank="showRank ? index + 1 : 0"
|
||||||
/>
|
/>
|
||||||
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
@@ -116,13 +152,15 @@ defineProps({
|
|||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 999;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.container > image:first-child {
|
.container > image:first-child {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
top: -5px;
|
/* top: -5px; */
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.players {
|
.players {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -153,15 +191,12 @@ defineProps({
|
|||||||
.players-melee {
|
.players-melee {
|
||||||
height: 80px;
|
height: 80px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
white-space: nowrap;
|
||||||
.players-melee::-webkit-scrollbar {
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
color: transparent;
|
|
||||||
}
|
}
|
||||||
/* 小程序 scroll-view 不支持直接 flex,通过内层 wrapper 承载横向排列 */
|
/* 小程序 scroll-view 不支持直接 flex,通过内层 wrapper 承载横向排列 */
|
||||||
.players-melee-inner {
|
.players-melee-inner {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
|
min-width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
@@ -172,7 +207,7 @@ defineProps({
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff9;
|
color: #fff9;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding-top: 7px;
|
/* padding-top: 7px; */
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.player-name {
|
.player-name {
|
||||||
@@ -183,6 +218,13 @@ defineProps({
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
text-align: center;
|
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 {
|
.left-winner-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
import AppBackground from "@/components/AppBackground.vue";
|
import AppBackground from "@/components/AppBackground.vue";
|
||||||
import Avatar from "@/components/Avatar.vue";
|
import Avatar from "@/components/Avatar.vue";
|
||||||
import BowTarget from "@/components/BowTarget.vue";
|
import BowTarget from "@/components/BowTarget.vue";
|
||||||
@@ -8,6 +9,9 @@ import { storeToRefs } from "pinia";
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user } = storeToRefs(store);
|
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({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -35,7 +39,21 @@ const props = defineProps({
|
|||||||
<view>
|
<view>
|
||||||
<Avatar :src="user.avatar" :rankLvl="user.rankLvl" :size="45" />
|
<Avatar :src="user.avatar" :rankLvl="user.rankLvl" :size="45" />
|
||||||
<view>
|
<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>
|
<text>{{ user.lvlName }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -44,7 +62,7 @@ const props = defineProps({
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view :style="{ width: '100%', marginBottom: '20px' }">
|
<view :style="{ width: '100%', marginBottom: '20px' }">
|
||||||
<BowTarget :scores="arrows" />
|
<BowTarget :scores="arrows" :isSvip="isSVip" />
|
||||||
</view>
|
</view>
|
||||||
<view class="desc">
|
<view class="desc">
|
||||||
<text>{{ arrows.length }}</text>
|
<text>{{ arrows.length }}</text>
|
||||||
@@ -95,6 +113,13 @@ const props = defineProps({
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
color: #fff;
|
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 {
|
.header > view:first-child > view:last-child > text:last-child {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
background-color: #5f51ff;
|
background-color: #5f51ff;
|
||||||
|
|||||||
@@ -4,18 +4,34 @@ import { getDeviceBatteryAPI } from "@/apis";
|
|||||||
|
|
||||||
const power = ref(0);
|
const power = ref(0);
|
||||||
const timer = ref(null);
|
const timer = ref(null);
|
||||||
|
let disposed = false;
|
||||||
|
let requestInFlight = false;
|
||||||
|
|
||||||
|
const refreshPower = async () => {
|
||||||
|
if (disposed || requestInFlight) return;
|
||||||
|
requestInFlight = true;
|
||||||
|
try {
|
||||||
|
const data = await getDeviceBatteryAPI();
|
||||||
|
if (!disposed) power.value = data.battery;
|
||||||
|
} catch (_) {
|
||||||
|
// 电量轮询失败时等待下一轮,避免产生未处理的 Promise 拒绝。
|
||||||
|
} finally {
|
||||||
|
requestInFlight = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const data = await getDeviceBatteryAPI();
|
await refreshPower();
|
||||||
power.value = data.battery;
|
if (disposed) return;
|
||||||
timer.value = setInterval(async () => {
|
timer.value = setInterval(() => {
|
||||||
const data = await getDeviceBatteryAPI();
|
void refreshPower();
|
||||||
power.value = data.battery;
|
|
||||||
}, 1000 * 10);
|
}, 1000 * 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true;
|
||||||
clearInterval(timer.value);
|
clearInterval(timer.value);
|
||||||
|
timer.value = null;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
<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,
|
||||||
|
},
|
||||||
|
targetLeft: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
targetTop: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
hitOffsetPx: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
viewportMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["complete", "impact"]);
|
||||||
|
|
||||||
|
const phase = ref("idle");
|
||||||
|
const activePlayKey = ref("");
|
||||||
|
const animationKey = ref("");
|
||||||
|
const impactEmitted = ref(false);
|
||||||
|
const activeShot = ref(null);
|
||||||
|
const activeLayout = ref(null);
|
||||||
|
let timers = [];
|
||||||
|
|
||||||
|
const isActive = computed(() => phase.value !== "idle");
|
||||||
|
const ARROW_IMPACT_MS = 340;
|
||||||
|
const COMPLETE_FALLBACK_MS = 980;
|
||||||
|
// 箭头尖端统一从屏幕中下区域出发,箭身自然延伸到屏幕底部之外。
|
||||||
|
const SHOT_START_VIEWPORT_Y_RATIO = 0.82;
|
||||||
|
|
||||||
|
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);
|
||||||
|
const left = Number(props.targetLeft);
|
||||||
|
const top = Number(props.targetTop);
|
||||||
|
return {
|
||||||
|
width: Number.isFinite(width) && width > 0 ? width : 0,
|
||||||
|
height: Number.isFinite(height) && height > 0 ? height : 0,
|
||||||
|
left: Number.isFinite(left) ? left : 0,
|
||||||
|
top: Number.isFinite(top) ? top : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function getWindowSize() {
|
||||||
|
try {
|
||||||
|
const info =
|
||||||
|
typeof uni.getWindowInfo === "function"
|
||||||
|
? uni.getWindowInfo()
|
||||||
|
: uni.getSystemInfoSync();
|
||||||
|
const width = Number(info?.windowWidth);
|
||||||
|
const height = Number(info?.windowHeight);
|
||||||
|
return {
|
||||||
|
width: Number.isFinite(width) && width > 0 ? width : 0,
|
||||||
|
height: Number.isFinite(height) && height > 0 ? height : 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasShotPoint(shot) {
|
||||||
|
const x = Number(shot?.x);
|
||||||
|
const y = Number(shot?.y);
|
||||||
|
return Number.isFinite(x) && Number.isFinite(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveShot = computed(() => activeShot.value || props.shot);
|
||||||
|
const effectiveTargetSize = computed(
|
||||||
|
() => activeLayout.value?.target || safeTargetSize.value
|
||||||
|
);
|
||||||
|
const effectiveWindowSize = computed(
|
||||||
|
() => activeLayout.value?.window || getWindowSize()
|
||||||
|
);
|
||||||
|
const isViewportMode = computed(() =>
|
||||||
|
activeLayout.value
|
||||||
|
? activeLayout.value.viewportMode
|
||||||
|
: props.viewportMode === true
|
||||||
|
);
|
||||||
|
|
||||||
|
const shotPoint = computed(() => {
|
||||||
|
const x = Number(effectiveShot.value?.x);
|
||||||
|
const y = Number(effectiveShot.value?.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 flightPath = computed(() => {
|
||||||
|
const size = effectiveTargetSize.value;
|
||||||
|
const windowSize = effectiveWindowSize.value;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isViewportMode.value &&
|
||||||
|
size.width > 0 &&
|
||||||
|
size.height > 0 &&
|
||||||
|
windowSize.width > 0 &&
|
||||||
|
windowSize.height > 0
|
||||||
|
) {
|
||||||
|
const startX = windowSize.width * 0.5;
|
||||||
|
const endX =
|
||||||
|
size.left +
|
||||||
|
size.width * (hitPercent.value.left / 100) +
|
||||||
|
hitOffset.value.x;
|
||||||
|
const endY =
|
||||||
|
size.top +
|
||||||
|
size.height * (hitPercent.value.top / 100) +
|
||||||
|
hitOffset.value.y;
|
||||||
|
// 常规情况下从视口底部进入;靶面局部超出视口时仍保证起点在命中点下方。
|
||||||
|
const startY = Math.max(windowSize.height + 16, endY + 80);
|
||||||
|
const dx = endX - startX;
|
||||||
|
const dy = endY - startY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
endX,
|
||||||
|
endY,
|
||||||
|
translateX: dx,
|
||||||
|
translateY: dy,
|
||||||
|
angle: Math.atan2(dx, -dy) * (180 / Math.PI),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasScreenCoordinates =
|
||||||
|
size.width > 0 &&
|
||||||
|
size.height > 0 &&
|
||||||
|
windowSize.width > 0 &&
|
||||||
|
windowSize.height > 0;
|
||||||
|
const startX = hasScreenCoordinates
|
||||||
|
? windowSize.width * 0.5 - size.left
|
||||||
|
: size.width * 0.5;
|
||||||
|
const startY = hasScreenCoordinates
|
||||||
|
? windowSize.height * SHOT_START_VIEWPORT_Y_RATIO - size.top
|
||||||
|
: 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 = endY - startY;
|
||||||
|
|
||||||
|
return {
|
||||||
|
startX,
|
||||||
|
startY,
|
||||||
|
endX,
|
||||||
|
endY,
|
||||||
|
translateX: dx,
|
||||||
|
translateY: dy,
|
||||||
|
angle: Math.atan2(dx, -dy) * (180 / Math.PI),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
if (isViewportMode.value) {
|
||||||
|
return {
|
||||||
|
left: `${flightPath.value.endX}px`,
|
||||||
|
top: `${flightPath.value.endY}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
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 = effectiveTargetSize.value;
|
||||||
|
const path = flightPath.value;
|
||||||
|
let x = getTargetTranslate(hitPercent.value.left - 50);
|
||||||
|
let y = getTargetTranslate(hitPercent.value.top - 114);
|
||||||
|
|
||||||
|
if (isViewportMode.value || (size.width && size.height)) {
|
||||||
|
x = `${path.translateX}px`;
|
||||||
|
y = `${path.translateY}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"--shot-start-x":
|
||||||
|
isViewportMode.value || size.width ? `${path.startX}px` : "50%",
|
||||||
|
"--shot-start-y":
|
||||||
|
isViewportMode.value || size.height ? `${path.startY}px` : "114%",
|
||||||
|
"--shot-tx": x,
|
||||||
|
"--shot-ty": y,
|
||||||
|
"--shot-angle": `${path.angle}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 = "";
|
||||||
|
activeShot.value = null;
|
||||||
|
activeLayout.value = null;
|
||||||
|
emit("complete", playKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (!props.playKey || !props.shot || !props.shot.ring || !hasShotPoint(props.shot)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimers();
|
||||||
|
activePlayKey.value = props.playKey;
|
||||||
|
animationKey.value = `${props.playKey}`;
|
||||||
|
impactEmitted.value = false;
|
||||||
|
activeShot.value = { ...props.shot };
|
||||||
|
activeLayout.value = {
|
||||||
|
target: { ...safeTargetSize.value },
|
||||||
|
window: getWindowSize(),
|
||||||
|
viewportMode: props.viewportMode === true,
|
||||||
|
};
|
||||||
|
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}`,
|
||||||
|
{ 'shot-effect--viewport': isViewportMode },
|
||||||
|
]"
|
||||||
|
:style="arrowMoveStyle"
|
||||||
|
>
|
||||||
|
<view
|
||||||
|
:key="`arrow-${animationKey}`"
|
||||||
|
class="shot-arrow-track"
|
||||||
|
@animationend="handleArrowAnimationEnd"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="shot-arrow"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/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="https://static.shelingxingqiu.com/shootmini/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-effect--viewport {
|
||||||
|
position: fixed;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-arrow-track {
|
||||||
|
position: absolute;
|
||||||
|
left: var(--shot-start-x);
|
||||||
|
top: var(--shot-start-y);
|
||||||
|
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>
|
||||||
+552
-77
@@ -1,9 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
import {
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
getCurrentInstance,
|
||||||
|
} from "vue";
|
||||||
import PointSwitcher from "@/components/PointSwitcher.vue";
|
import PointSwitcher from "@/components/PointSwitcher.vue";
|
||||||
|
import BowShotEffect from "@/components/BowShotEffect.vue";
|
||||||
|
|
||||||
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||||||
import { simulShootAPI } from "@/apis";
|
import { simulShootAPI, laserAimAPI, laserCloseAPI } from "@/apis";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -26,78 +35,425 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
isSvip: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
mode: {
|
mode: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "solo", // solo 单排,team 双排
|
default: "solo", // solo 单排,team 双排
|
||||||
},
|
},
|
||||||
|
missAsZero: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
stop: {
|
stop: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
targetType: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 40,
|
||||||
|
},
|
||||||
|
targetRadius: {
|
||||||
|
type: Number,
|
||||||
|
default: 20,
|
||||||
|
},
|
||||||
|
hitRadiusPx: {
|
||||||
|
type: Number,
|
||||||
|
default: 2,
|
||||||
|
},
|
||||||
|
zoomHitRadiusPx: {
|
||||||
|
type: Number,
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
stableShotEffect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
enableShotEffect: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const pMode = ref(true);
|
const pMode = ref(true);
|
||||||
const latestOne = ref(null);
|
const latestOne = ref(null);
|
||||||
const bluelatestOne = ref(null);
|
const bluelatestOne = ref(null);
|
||||||
const prevScores = ref([]);
|
|
||||||
const prevBlueScores = ref([]);
|
|
||||||
const timer = ref(null);
|
const timer = ref(null);
|
||||||
const dirTimer = ref(null);
|
const dirTimer = ref(null);
|
||||||
const angle = ref(null);
|
const angle = ref(null);
|
||||||
const circleColor = ref("");
|
const circleColor = ref("");
|
||||||
|
const shotEffect = ref(null);
|
||||||
|
const pendingShotEffect = ref(null);
|
||||||
|
const hiddenRedLatestKey = ref("");
|
||||||
|
const hiddenBlueLatestKey = ref("");
|
||||||
|
const targetShaking = ref(false);
|
||||||
|
const targetRect = ref({ left: 0, top: 0, width: 0, height: 0 });
|
||||||
|
const shakeTimer = ref(null);
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
let shotEffectRequestGeneration = 0;
|
||||||
|
const ROUND_TIP_OFFSET_Y = -32;
|
||||||
|
const EXPERIENCE_TIP_OFFSET_Y = -68;
|
||||||
|
|
||||||
watch(
|
function getRoundText(shot) {
|
||||||
() => props.scores,
|
if (shot?.ringX) return "X环";
|
||||||
(newVal) => {
|
if (shot?.ring) return `${shot.ring}环`;
|
||||||
if (newVal.length - prevScores.value.length === 1) {
|
return props.missAsZero ? "0环" : "未上靶";
|
||||||
latestOne.value = newVal[newVal.length - 1];
|
}
|
||||||
if (timer.value) clearTimeout(timer.value);
|
|
||||||
|
function buildShotEffectKey(team, shot, index) {
|
||||||
|
return [
|
||||||
|
team,
|
||||||
|
index,
|
||||||
|
shot?.playerId ?? "",
|
||||||
|
shot?.x ?? "",
|
||||||
|
shot?.y ?? "",
|
||||||
|
shot?.ring ?? "",
|
||||||
|
shot?.ringX ? 1 : 0,
|
||||||
|
].join("-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasShotPoint(shot) {
|
||||||
|
const x = Number(shot?.x);
|
||||||
|
const y = Number(shot?.y);
|
||||||
|
return Number.isFinite(x) && Number.isFinite(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPlayShotEffect(shot) {
|
||||||
|
return (
|
||||||
|
props.enableShotEffect &&
|
||||||
|
props.isSvip &&
|
||||||
|
!!shot &&
|
||||||
|
Number(shot.ring) > 0 &&
|
||||||
|
hasShotPoint(shot)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {
|
timer.value = setTimeout(() => {
|
||||||
latestOne.value = null;
|
latestOne.value = null;
|
||||||
|
timer.value = null;
|
||||||
|
}, 1000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bluelatestOne.value = shot;
|
||||||
|
timer.value = setTimeout(() => {
|
||||||
|
bluelatestOne.value = null;
|
||||||
|
timer.value = null;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
prevScores.value = [...newVal];
|
|
||||||
},
|
function triggerShotEffect(team, shot, index, viewportMode = false) {
|
||||||
{
|
const key = buildShotEffectKey(team, shot, index);
|
||||||
deep: true,
|
|
||||||
|
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, viewportMode };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareShotEffect(team, shot, index) {
|
||||||
|
const requestGeneration = ++shotEffectRequestGeneration;
|
||||||
|
const key = buildShotEffectKey(team, shot, index);
|
||||||
|
pendingShotEffect.value = { generation: requestGeneration, team, key };
|
||||||
|
clearTipTimer();
|
||||||
|
if (team === "red") latestOne.value = null;
|
||||||
|
if (team === "blue") bluelatestOne.value = null;
|
||||||
|
|
||||||
|
const viewportMode = props.stableShotEffect
|
||||||
|
? await updateTargetRect()
|
||||||
|
: false;
|
||||||
|
|
||||||
|
if (requestGeneration !== shotEffectRequestGeneration) {
|
||||||
|
if (pendingShotEffect.value?.generation === requestGeneration) {
|
||||||
|
pendingShotEffect.value = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingShotEffect.value = null;
|
||||||
|
triggerShotEffect(team, shot, index, viewportMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 hasValidTargetRect(rect = targetRect.value) {
|
||||||
|
return (
|
||||||
|
Number.isFinite(Number(rect?.left)) &&
|
||||||
|
Number.isFinite(Number(rect?.top)) &&
|
||||||
|
Number(rect?.width) > 0 &&
|
||||||
|
Number(rect?.height) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateTargetRect() {
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (rect) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
|
||||||
|
const isValid = hasValidTargetRect(rect);
|
||||||
|
if (isValid) {
|
||||||
|
targetRect.value = {
|
||||||
|
left: Number(rect.left),
|
||||||
|
top: Number(rect.top),
|
||||||
|
width: Number(rect.width),
|
||||||
|
height: Number(rect.height),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
resolve(isValid);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = instance?.proxy
|
||||||
|
? uni.createSelectorQuery().in(instance.proxy)
|
||||||
|
: uni.createSelectorQuery();
|
||||||
|
|
||||||
|
query
|
||||||
|
.select(".target")
|
||||||
|
.boundingClientRect()
|
||||||
|
.exec((result) => finish(Array.isArray(result) ? result[0] : null));
|
||||||
|
} catch {
|
||||||
|
finish(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowResize() {
|
||||||
|
void updateTargetRect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHideRedHit(index) {
|
||||||
|
return (
|
||||||
|
(!!hiddenRedLatestKey.value || pendingShotEffect.value?.team === "red") &&
|
||||||
|
index === props.scores.length - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHideBlueHit(index) {
|
||||||
|
return (
|
||||||
|
(!!hiddenBlueLatestKey.value || pendingShotEffect.value?.team === "blue") &&
|
||||||
|
index === props.blueScores.length - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.scores.length,
|
||||||
|
(newLen, oldLen) => {
|
||||||
|
if (newLen === oldLen + 1) {
|
||||||
|
const latestShot = props.scores[newLen - 1];
|
||||||
|
if (shouldPlayShotEffect(latestShot)) {
|
||||||
|
void prepareShotEffect("red", latestShot, newLen - 1);
|
||||||
|
} else if (props.enableShotEffect) {
|
||||||
|
shotEffectRequestGeneration += 1;
|
||||||
|
pendingShotEffect.value = null;
|
||||||
|
showShotTip("red", latestShot);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newLen < oldLen) {
|
||||||
|
shotEffectRequestGeneration += 1;
|
||||||
|
pendingShotEffect.value = null;
|
||||||
|
latestOne.value = null;
|
||||||
|
hiddenRedLatestKey.value = "";
|
||||||
|
if (shotEffect.value?.team === "red") shotEffect.value = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.blueScores,
|
() => props.blueScores.length,
|
||||||
(newVal) => {
|
(newLen, oldLen) => {
|
||||||
if (newVal.length - prevBlueScores.value.length === 1) {
|
if (newLen === oldLen + 1) {
|
||||||
bluelatestOne.value = newVal[newVal.length - 1];
|
const latestShot = props.blueScores[newLen - 1];
|
||||||
if (timer.value) clearTimeout(timer.value);
|
if (shouldPlayShotEffect(latestShot)) {
|
||||||
timer.value = setTimeout(() => {
|
void prepareShotEffect("blue", latestShot, newLen - 1);
|
||||||
|
} else if (props.enableShotEffect) {
|
||||||
|
shotEffectRequestGeneration += 1;
|
||||||
|
pendingShotEffect.value = null;
|
||||||
|
showShotTip("blue", latestShot);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newLen < oldLen) {
|
||||||
|
shotEffectRequestGeneration += 1;
|
||||||
|
pendingShotEffect.value = null;
|
||||||
bluelatestOne.value = null;
|
bluelatestOne.value = null;
|
||||||
}, 1000);
|
hiddenBlueLatestKey.value = "";
|
||||||
|
if (shotEffect.value?.team === "blue") shotEffect.value = null;
|
||||||
}
|
}
|
||||||
prevBlueScores.value = [...newVal];
|
|
||||||
},
|
|
||||||
{
|
|
||||||
deep: true,
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
function calcRealX(num, offset = 3.4) {
|
const safeTargetRadius = computed(() => {
|
||||||
const len = 20.4 + num;
|
const radius = Number(props.targetRadius);
|
||||||
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
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;
|
function getPointDirection(point) {
|
||||||
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
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 () => {
|
const simulShoot = async () => {
|
||||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
if (device.value.deviceId) {
|
||||||
|
await simulShootAPI(
|
||||||
|
device.value.deviceId,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
props.targetType
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const simulShoot2 = async () => {
|
const simulShoot2 = async () => {
|
||||||
if (device.value.deviceId) {
|
if (device.value.deviceId) {
|
||||||
const r1 = Math.random() > 0.5 ? 0.01 : 0.02;
|
const r1 = Math.random() > 0.5 ? 0.01 : 0.02;
|
||||||
await simulShootAPI(device.value.deviceId, r1, r1);
|
await simulShootAPI(device.value.deviceId, r1, r1, props.targetType);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAim = async () => {
|
||||||
|
await laserAimAPI();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAim = async () => {
|
||||||
|
await laserCloseAPI();
|
||||||
|
};
|
||||||
|
|
||||||
const env = computed(() => {
|
const env = computed(() => {
|
||||||
const accountInfo = uni.getAccountInfoSync();
|
const accountInfo = uni.getAccountInfoSync();
|
||||||
return accountInfo.miniProgram.envVersion;
|
return accountInfo.miniProgram.envVersion;
|
||||||
@@ -135,9 +491,13 @@ async function onReceiveMessage(message) {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
uni.$on("socket-inbox", onReceiveMessage);
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
|
void updateTargetRect();
|
||||||
|
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
shotEffectRequestGeneration += 1;
|
||||||
|
pendingShotEffect.value = null;
|
||||||
if (timer.value) {
|
if (timer.value) {
|
||||||
clearTimeout(timer.value);
|
clearTimeout(timer.value);
|
||||||
timer.value = null;
|
timer.value = null;
|
||||||
@@ -146,12 +506,17 @@ onBeforeUnmount(() => {
|
|||||||
clearTimeout(dirTimer.value);
|
clearTimeout(dirTimer.value);
|
||||||
dirTimer.value = null;
|
dirTimer.value = null;
|
||||||
}
|
}
|
||||||
|
if (shakeTimer.value) {
|
||||||
|
clearTimeout(shakeTimer.value);
|
||||||
|
shakeTimer.value = null;
|
||||||
|
}
|
||||||
uni.$off("socket-inbox", onReceiveMessage);
|
uni.$off("socket-inbox", onReceiveMessage);
|
||||||
|
if (uni.offWindowResize) uni.offWindowResize(handleWindowResize);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view :class="['container', { 'container--effecting': shotEffect }]">
|
||||||
<view class="header" v-if="totalRound > 0">
|
<view class="header" v-if="totalRound > 0">
|
||||||
<text v-if="totalRound > 0" class="round-count">{{
|
<text v-if="totalRound > 0" class="round-count">{{
|
||||||
(currentRound > totalRound ? totalRound : currentRound) +
|
(currentRound > totalRound ? totalRound : currentRound) +
|
||||||
@@ -159,7 +524,7 @@ onBeforeUnmount(() => {
|
|||||||
totalRound
|
totalRound
|
||||||
}}</text>
|
}}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="target">
|
<view :class="['target', { 'target--shake': targetShaking }]">
|
||||||
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
||||||
<view :style="{ background: circleColor }">
|
<view :style="{ background: circleColor }">
|
||||||
<image src="../static/dot-circle.png" mode="widthFix" />
|
<image src="../static/dot-circle.png" mode="widthFix" />
|
||||||
@@ -169,22 +534,15 @@ onBeforeUnmount(() => {
|
|||||||
<view
|
<view
|
||||||
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
||||||
class="e-value fade-in-out"
|
class="e-value fade-in-out"
|
||||||
:style="{
|
:style="getExperienceTipStyle(latestOne)"
|
||||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 20),
|
|
||||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 40),
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
经验 +1
|
经验 +1
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
v-if="latestOne"
|
v-if="latestOne"
|
||||||
class="round-tip fade-in-out"
|
class="round-tip fade-in-out"
|
||||||
:style="{
|
:style="getRoundTipStyle(latestOne)"
|
||||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 28),
|
>{{ getRoundText(latestOne) }}
|
||||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 28),
|
|
||||||
}"
|
|
||||||
>{{ latestOne.ringX ? "X" : latestOne.ring || "未上靶"
|
|
||||||
}}<text v-if="latestOne.ring">环</text>
|
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
v-if="
|
v-if="
|
||||||
@@ -193,54 +551,86 @@ onBeforeUnmount(() => {
|
|||||||
user.id === bluelatestOne.playerId
|
user.id === bluelatestOne.playerId
|
||||||
"
|
"
|
||||||
class="e-value fade-in-out"
|
class="e-value fade-in-out"
|
||||||
:style="{
|
:style="getExperienceTipStyle(bluelatestOne)"
|
||||||
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 20),
|
|
||||||
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 40),
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
经验 +1
|
经验 +1
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
v-if="bluelatestOne"
|
v-if="bluelatestOne"
|
||||||
class="round-tip fade-in-out"
|
class="round-tip fade-in-out"
|
||||||
:style="{
|
:style="getRoundTipStyle(bluelatestOne)"
|
||||||
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 28),
|
>{{ getRoundText(bluelatestOne) }}</view
|
||||||
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 28),
|
|
||||||
}"
|
|
||||||
>{{ bluelatestOne.ringX ? "X" : bluelatestOne.ring || "未上靶"
|
|
||||||
}}<text v-if="bluelatestOne.ring">环</text></view
|
|
||||||
>
|
>
|
||||||
<block v-for="(bow, index) in scores" :key="index">
|
<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
|
<view
|
||||||
v-if="bow.ring > 0"
|
v-if="bow.ring > 0 && !shouldHideRedHit(index)"
|
||||||
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||||
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
||||||
}`"
|
}`"
|
||||||
:style="{
|
:style="{
|
||||||
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
...getHitStyle(bow),
|
||||||
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
|
||||||
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
|
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
|
||||||
}"
|
}"
|
||||||
><text v-if="pMode">{{ index + 1 }}</text></view
|
><text v-if="pMode">{{ index + 1 }}</text></view
|
||||||
>
|
>
|
||||||
</block>
|
</block>
|
||||||
<block v-for="(bow, index) in blueScores" :key="index">
|
<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
|
<view
|
||||||
v-if="bow.ring > 0"
|
v-if="bow.ring > 0 && !shouldHideBlueHit(index)"
|
||||||
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||||
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
|
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
|
||||||
}`"
|
}`"
|
||||||
:style="{
|
:style="{
|
||||||
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
...getHitStyle(bow),
|
||||||
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
|
||||||
backgroundColor: '#1840FF',
|
backgroundColor: '#1840FF',
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<text v-if="pMode">{{ index + 1 }}</text>
|
<text v-if="pMode">{{ index + 1 }}</text>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<image src="../static/bow-target.png" mode="widthFix" />
|
<BowShotEffect
|
||||||
|
v-if="!shotEffect || !shotEffect.viewportMode"
|
||||||
|
:shot="shotEffect && shotEffect.shot"
|
||||||
|
:playKey="shotEffect ? shotEffect.key : ''"
|
||||||
|
:targetRadius="safeTargetRadius"
|
||||||
|
:targetLeft="targetRect.left"
|
||||||
|
:targetTop="targetRect.top"
|
||||||
|
:targetWidth="targetRect.width"
|
||||||
|
:targetHeight="targetRect.height"
|
||||||
|
:hitOffsetPx="currentHitRadiusPx"
|
||||||
|
@impact="shakeTarget"
|
||||||
|
@complete="completeShotEffect"
|
||||||
|
/>
|
||||||
|
<image src="https://static.shelingxingqiu.com/shootmini/static/bow-target.png" mode="widthFix" />
|
||||||
</view>
|
</view>
|
||||||
|
<BowShotEffect
|
||||||
|
v-if="shotEffect && shotEffect.viewportMode"
|
||||||
|
:shot="shotEffect.shot"
|
||||||
|
:playKey="shotEffect.key"
|
||||||
|
:targetRadius="safeTargetRadius"
|
||||||
|
:targetLeft="targetRect.left"
|
||||||
|
:targetTop="targetRect.top"
|
||||||
|
:targetWidth="targetRect.width"
|
||||||
|
:targetHeight="targetRect.height"
|
||||||
|
:hitOffsetPx="currentHitRadiusPx"
|
||||||
|
:viewportMode="true"
|
||||||
|
@impact="shakeTarget"
|
||||||
|
@complete="completeShotEffect"
|
||||||
|
/>
|
||||||
<view class="footer">
|
<view class="footer">
|
||||||
<PointSwitcher
|
<PointSwitcher
|
||||||
:onChange="(val) => (pMode = val)"
|
:onChange="(val) => (pMode = val)"
|
||||||
@@ -250,6 +640,8 @@ onBeforeUnmount(() => {
|
|||||||
<view class="simul" v-if="env !== 'release'">
|
<view class="simul" v-if="env !== 'release'">
|
||||||
<button @click="simulShoot">模拟</button>
|
<button @click="simulShoot">模拟</button>
|
||||||
<button @click="simulShoot2">射箭</button>
|
<button @click="simulShoot2">射箭</button>
|
||||||
|
<button @click="openAim">开瞄</button>
|
||||||
|
<button @click="closeAim">关瞄</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -260,13 +652,22 @@ onBeforeUnmount(() => {
|
|||||||
height: calc(100vw - 30px);
|
height: calc(100vw - 30px);
|
||||||
padding: 0px 15px;
|
padding: 0px 15px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.container--effecting {
|
||||||
|
z-index: 10000;
|
||||||
}
|
}
|
||||||
.target {
|
.target {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
height: calc(100% - 20px);
|
height: calc(100% - 20px);
|
||||||
z-index: -1;
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
.target--shake {
|
||||||
|
animation: target-shake 0.26s ease-out;
|
||||||
}
|
}
|
||||||
.e-value {
|
.e-value {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -292,31 +693,55 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
margin-left: 5px;
|
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 {
|
.target > image:last-child {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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 {
|
.hit {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
transition: all 0.3s ease;
|
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||||
}
|
box-sizing: border-box;
|
||||||
.s-point {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
min-width: 4px;
|
|
||||||
min-height: 4px;
|
|
||||||
}
|
}
|
||||||
.b-point {
|
.b-point {
|
||||||
width: 10px;
|
|
||||||
height: 10px;
|
|
||||||
min-width: 10px;
|
|
||||||
min-height: 10px;
|
|
||||||
border: 1px solid #fff;
|
border: 1px solid #fff;
|
||||||
z-index: 1;
|
z-index: 2;
|
||||||
box-sizing: border-box;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -332,6 +757,56 @@ onBeforeUnmount(() => {
|
|||||||
transform: translate(-50%, -50%);*/
|
transform: translate(-50%, -50%);*/
|
||||||
margin-top: 2rpx;
|
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 {
|
.header {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
import { ref } from "vue";
|
||||||
import { onShow } from "@dcloudio/uni-app";
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
import AppBackground from "@/components/AppBackground.vue";
|
import AppBackground from "@/components/AppBackground.vue";
|
||||||
import Header from "@/components/Header.vue";
|
import Header from "@/components/Header.vue";
|
||||||
import ScreenHint from "@/components/ScreenHint.vue";
|
import ScreenHint from "@/components/ScreenHint.vue";
|
||||||
import BackToGame from "@/components/BackToGame.vue";
|
import BackToGame from "@/components/BackToGame.vue";
|
||||||
|
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||||
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
||||||
import { capsuleHeight, debounce } from "@/util";
|
import { capsuleHeight, debounce } from "@/util";
|
||||||
import AudioManager from "@/audioManager";
|
import { returnToBattle } from "@/utils/matchReturn";
|
||||||
|
const emit = defineEmits(["scrolltolower"]);
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -25,6 +27,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
usePageScroll: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
isHome: {
|
isHome: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -41,18 +47,31 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
headerClass: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
titleStyle: {
|
||||||
|
type: [String, Object, Array],
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
showBottom: {
|
showBottom: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const isIOS = uni.getDeviceInfo().osName === "ios";
|
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||||
const showHint = ref(false);
|
const showHint = ref(false);
|
||||||
const hintType = ref(0);
|
const hintType = ref(0);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const audioInitProgress = ref(1);
|
|
||||||
const audioProgress = ref(0);
|
|
||||||
const audioTimer = ref(null);
|
|
||||||
|
|
||||||
const showGlobalHint = (type) => {
|
const showGlobalHint = (type) => {
|
||||||
hintType.value = type;
|
hintType.value = type;
|
||||||
@@ -63,49 +82,21 @@ const hideGlobalHint = () => {
|
|||||||
showHint.value = false;
|
showHint.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
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(() => {
|
onShow(() => {
|
||||||
uni.$showHint = showGlobalHint;
|
uni.$showHint = showGlobalHint;
|
||||||
uni.$hideHint = hideGlobalHint;
|
uni.$hideHint = hideGlobalHint;
|
||||||
uni.$checkAudio = checkAudioProgress;
|
|
||||||
showHint.value = false;
|
showHint.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const navigateTo = (url) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url,
|
||||||
|
success: resolve,
|
||||||
|
fail: reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const backToGame = debounce(async () => {
|
const backToGame = debounce(async () => {
|
||||||
if (isLoading.value) return; // 防止重复点击
|
if (isLoading.value) return; // 防止重复点击
|
||||||
|
|
||||||
@@ -113,16 +104,7 @@ const backToGame = debounce(async () => {
|
|||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
const result = await getBattleAPI();
|
const result = await getBattleAPI();
|
||||||
if (result && result.matchId) {
|
if (result && result.matchId) {
|
||||||
await checkAudioProgress();
|
await returnToBattle(result, navigateTo);
|
||||||
if (result.mode <= 3) {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/team-battle/index?battleId=${result.matchId}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/melee-battle?battleId=${result.matchId}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("获取当前游戏失败:", error);
|
console.error("获取当前游戏失败:", error);
|
||||||
@@ -150,20 +132,38 @@ const goCalibration = async () => {
|
|||||||
<template>
|
<template>
|
||||||
<view :style="{ paddingTop: capsuleHeight + 'px' }">
|
<view :style="{ paddingTop: capsuleHeight + 'px' }">
|
||||||
<AppBackground :type="bgType" :bgColor="bgColor" />
|
<AppBackground :type="bgType" :bgColor="bgColor" />
|
||||||
|
<slot v-if="$slots.header" name="header"></slot>
|
||||||
<Header
|
<Header
|
||||||
v-if="!isHome"
|
v-else-if="!isHome"
|
||||||
|
:class="headerClass"
|
||||||
:title="title"
|
:title="title"
|
||||||
:onBack="onBack"
|
:onBack="onBack"
|
||||||
:whiteBackArrow="whiteBackArrow"
|
:whiteBackArrow="whiteBackArrow"
|
||||||
|
:titleStyle="titleStyle"
|
||||||
|
:style="
|
||||||
|
usePageScroll
|
||||||
|
? {
|
||||||
|
position: 'sticky',
|
||||||
|
top: capsuleHeight + 'px',
|
||||||
|
zIndex: 10,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
<BackToGame v-if="showBackToGame" />
|
<BackToGame v-if="showBackToGame" />
|
||||||
|
<view v-if="usePageScroll">
|
||||||
|
<slot></slot>
|
||||||
|
</view>
|
||||||
<scroll-view
|
<scroll-view
|
||||||
|
v-else
|
||||||
:scroll-y="scroll"
|
:scroll-y="scroll"
|
||||||
:enhanced="true"
|
:enhanced="true"
|
||||||
:bounces="false"
|
:bounces="false"
|
||||||
:show-scrollbar="false"
|
:show-scrollbar="false"
|
||||||
|
:lower-threshold="120"
|
||||||
|
@scrolltolower="emit('scrolltolower')"
|
||||||
:style="{
|
:style="{
|
||||||
height: `calc(100vh - ${capsuleHeight + (isHome ? 0 : 50)}px - ${
|
height: `calc(100vh - ${capsuleHeight + (($slots.header || !isHome) ? 50 : 0)}px - ${
|
||||||
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
||||||
})`,
|
})`,
|
||||||
}"
|
}"
|
||||||
@@ -219,24 +219,19 @@ const goCalibration = async () => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</ScreenHint>
|
</ScreenHint>
|
||||||
<view v-if="audioInitProgress < 1" class="audio-progress">
|
<view v-if="loading" class="audio-progress">
|
||||||
<image
|
<image
|
||||||
src="https://static.shelingxingqiu.com/attachment/2025-11-26/deihtj15xjwcz3c1tx.png"
|
src="https://static.shelingxingqiu.com/attachment/2025-11-26/deihtj15xjwcz3c1tx.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<view>
|
<view>
|
||||||
<view :style="{ width: `${audioFinalProgress * 100}%` }">
|
<view :style="{ width: '100%' }"></view>
|
||||||
<!-- <image
|
|
||||||
src="https://static.shelingxingqiu.com/attachment/2025-11-24/degu91a7si77sg9jqv.png"
|
|
||||||
mode="widthFix"
|
|
||||||
/> -->
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
<view>
|
<view>
|
||||||
<text>若加载时间过长,请</text>
|
<text>{{ loadingText || "加载中..." }}</text>
|
||||||
<button hover-class="none" @click="restart">点击这里重启</button>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<DeviceChargingDialog />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -278,6 +273,7 @@ const goCalibration = async () => {
|
|||||||
color: #666;
|
color: #666;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-progress {
|
.audio-progress {
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
@@ -291,11 +287,13 @@ const goCalibration = async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-progress > image:nth-child(1) {
|
.audio-progress > image:nth-child(1) {
|
||||||
width: 140rpx;
|
width: 140rpx;
|
||||||
height: 150rpx;
|
height: 150rpx;
|
||||||
margin-bottom: 20rpx;
|
margin-bottom: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-progress > view:nth-child(2) {
|
.audio-progress > view:nth-child(2) {
|
||||||
width: 380rpx;
|
width: 380rpx;
|
||||||
height: 6rpx;
|
height: 6rpx;
|
||||||
@@ -306,34 +304,24 @@ const goCalibration = async () => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-progress > view:nth-child(2) > view {
|
.audio-progress > view:nth-child(2) > view {
|
||||||
background: #ffe431;
|
width: 100%;
|
||||||
min-height: 6rpx;
|
min-height: 6rpx;
|
||||||
|
background: #ffe431;
|
||||||
border-radius: 4rpx;
|
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) {
|
.audio-progress > view:nth-child(3) {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-progress > view:nth-child(3) > text {
|
.audio-progress > view:nth-child(3) > text {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #a2a2a2;
|
color: #a2a2a2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 32rpx;
|
line-height: 32rpx;
|
||||||
}
|
}
|
||||||
.audio-progress > view:nth-child(3) > button {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: #ffe431;
|
|
||||||
line-height: 32rpx;
|
|
||||||
padding: 20rpx 0;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const createRoom = debounce(async () => {
|
|||||||
}
|
}
|
||||||
if (loading.value === true) return;
|
if (loading.value === true) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
let keepLoading = false;
|
||||||
let size = 2;
|
let size = 2;
|
||||||
if (battleMode.value === 2) size = 10;
|
if (battleMode.value === 2) size = 10;
|
||||||
if (battleMode.value === 3) size = 4;
|
if (battleMode.value === 3) size = 4;
|
||||||
@@ -49,14 +50,18 @@ const createRoom = debounce(async () => {
|
|||||||
if (result.number) {
|
if (result.number) {
|
||||||
props.onConfirm();
|
props.onConfirm();
|
||||||
await joinRoomAPI(result.number);
|
await joinRoomAPI(result.number);
|
||||||
|
keepLoading = true;
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: "/pages/battle-room?roomNumber=" + result.number + "&target=" + targetMode.value,
|
url: "/pages/battle-room?roomNumber=" + result.number + "&target=" + targetMode.value,
|
||||||
|
fail: () => {
|
||||||
|
loading.value = false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
console.log(error);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (!keepLoading) loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -64,7 +69,7 @@ const createRoom = debounce(async () => {
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<view class="target-options-header">
|
<view class="target-options-header">
|
||||||
<view class="target-options-header-line-left"></view>
|
<view class="target-options-header-line-left"></view>
|
||||||
<image class="target-options-header-title-img" src="../static/choose-battle-mode.png" mode="widthFix" />
|
<image class="target-options-header-title-img" src="https://static.shelingxingqiu.com/shootmini/static/choose-battle-mode.png" mode="widthFix" />
|
||||||
<view class="target-options-header-line-right"></view>
|
<view class="target-options-header-line-right"></view>
|
||||||
</view>
|
</view>
|
||||||
<view class="create-options">
|
<view class="create-options">
|
||||||
@@ -112,7 +117,7 @@ const createRoom = debounce(async () => {
|
|||||||
<text>40厘米全环靶</text>
|
<text>40厘米全环靶</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<SButton :onClick="() => $clickSound(createRoom)">创建房间</SButton>
|
<SButton :disabled="loading" :onClick="() => { $clickSound(); return createRoom(); }">创建房间</SButton>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup>
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import ModalDialog from "@/components/ModalDialog.vue";
|
||||||
|
import useStore from "@/store";
|
||||||
|
|
||||||
|
const chargingNotice =
|
||||||
|
"检测到智能弓箭已开始充电,为保护电池,设备即将自动关机。\n(注意:此时拔出充电线将会重启设备。)";
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { deviceChargingDialogVisible } = storeToRefs(store);
|
||||||
|
const { hideDeviceChargingDialog } = store;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ModalDialog
|
||||||
|
:show="deviceChargingDialogVisible"
|
||||||
|
:content="chargingNotice"
|
||||||
|
:show-cancel="false"
|
||||||
|
confirm-text="我知道了"
|
||||||
|
:on-confirm="hideDeviceChargingDialog"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -12,13 +12,13 @@ defineProps({
|
|||||||
const bubbleTypes = [
|
const bubbleTypes = [
|
||||||
"../static/long-bubble.png",
|
"../static/long-bubble.png",
|
||||||
"../static/long-bubble-middle.png",
|
"../static/long-bubble-middle.png",
|
||||||
"../static/long-bubble-tall.png",
|
"https://static.shelingxingqiu.com/shootmini/static/long-bubble-tall.png",
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<image src="../static/shooter.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/shooter.png" mode="widthFix" />
|
||||||
<view>
|
<view>
|
||||||
<image
|
<image
|
||||||
v-if="!noBg"
|
v-if="!noBg"
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
|
titleStyle: {
|
||||||
|
type: [String, Object, Array],
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onClick = () => {
|
const onClick = () => {
|
||||||
@@ -55,6 +59,9 @@ 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 loading = ref(false);
|
||||||
const pointBook = ref(null);
|
const pointBook = ref(null);
|
||||||
const heat = ref(0);
|
const heat = ref(0);
|
||||||
@@ -115,7 +122,11 @@ onBeforeUnmount(() => {
|
|||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view :style="{ color: whiteBackArrow ? '#fff' : '#000' }">
|
<view
|
||||||
|
:style="[{ color: whiteBackArrow ? '#fff' : '#000' }, titleStyle]"
|
||||||
|
>
|
||||||
|
<slot v-if="$slots.title" name="title"></slot>
|
||||||
|
<template v-else>
|
||||||
<view
|
<view
|
||||||
v-if="currentPage === 'pages/point-book'"
|
v-if="currentPage === 'pages/point-book'"
|
||||||
class="user-header"
|
class="user-header"
|
||||||
@@ -128,7 +139,21 @@ onBeforeUnmount(() => {
|
|||||||
:size="40"
|
:size="40"
|
||||||
borderColor="#333"
|
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
|
<image
|
||||||
v-if="heat"
|
v-if="heat"
|
||||||
:src="`../static/hot${heat}.png`"
|
:src="`../static/hot${heat}.png`"
|
||||||
@@ -142,8 +167,8 @@ onBeforeUnmount(() => {
|
|||||||
</view>
|
</view>
|
||||||
<block
|
<block
|
||||||
v-if="
|
v-if="
|
||||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) === -1 ||
|
'-箭前准备-感知距离-小试牛刀'.indexOf(title) === -1 ||
|
||||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) === 10
|
'-箭前准备-感知距离-小试牛刀'.indexOf(title) === 11
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<text>{{ title }}</text>
|
<text>{{ title }}</text>
|
||||||
@@ -151,12 +176,12 @@ onBeforeUnmount(() => {
|
|||||||
<block
|
<block
|
||||||
v-if="
|
v-if="
|
||||||
title &&
|
title &&
|
||||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== -1 &&
|
'-箭前准备-感知距离-小试牛刀'.indexOf(title) !== -1 &&
|
||||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== 10
|
'-箭前准备-感知距离-小试牛刀'.indexOf(title) !== 11
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<view class="first-try-steps">
|
<view class="first-try-steps">
|
||||||
<text :class="title === '-凹造型' ? 'current-step' : ''">凹造型</text>
|
<text :class="title === '-箭前准备' ? 'current-step' : ''">箭前准备</text>
|
||||||
<text>-</text>
|
<text>-</text>
|
||||||
<text :class="title === '-感知距离' ? 'current-step' : ''"
|
<text :class="title === '-感知距离' ? 'current-step' : ''"
|
||||||
>感知距离</text
|
>感知距离</text
|
||||||
@@ -167,6 +192,7 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
|
</template>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="pointBook" class="point-book-info">
|
<view v-if="pointBook" class="point-book-info">
|
||||||
<text>{{ pointBook.bowType.name }}</text>
|
<text>{{ pointBook.bowType.name }}</text>
|
||||||
@@ -285,7 +311,8 @@ onBeforeUnmount(() => {
|
|||||||
width: 36rpx;
|
width: 36rpx;
|
||||||
height: 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-weight: 500;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
color: #333333;
|
color: #333333;
|
||||||
@@ -316,7 +343,7 @@ onBeforeUnmount(() => {
|
|||||||
width: 156rpx;
|
width: 156rpx;
|
||||||
height: 28rpx;
|
height: 28rpx;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 20rpx;
|
font-size: 24rpx;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
line-height: 28rpx;
|
line-height: 28rpx;
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||||
import audioManager from "@/audioManager";
|
import audioManager from "@/audioManager";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
import { getDirectionText } from "@/util";
|
import {
|
||||||
|
getDirectionText,
|
||||||
|
getInvalidShotAudioKey,
|
||||||
|
getInvalidShotText,
|
||||||
|
} from "@/util";
|
||||||
|
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
@@ -88,10 +92,10 @@ async function onReceiveMessage(message) {
|
|||||||
currentRoundEnded.value = true;
|
currentRoundEnded.value = true;
|
||||||
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: "距离不足,无效",
|
title: getInvalidShotText(shootData),
|
||||||
icon: "none",
|
icon: "none",
|
||||||
});
|
});
|
||||||
audioManager.play("射击无效");
|
audioManager.play(getInvalidShotAudioKey(shootData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<view class="matching">
|
<view class="matching">
|
||||||
<image
|
<image
|
||||||
src="../static/matching-bg.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/matching-bg.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
class="matching-bg"
|
class="matching-bg"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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="https://static.shelingxingqiu.com/shootmini/static/common/dialog-light.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="dialog-icon"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/common/dialog-icon.png"
|
||||||
|
mode="widthFix"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view class="dialog-panel">
|
||||||
|
<image
|
||||||
|
class="dialog-bg"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/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>
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { getDeviceBatteryAPI } from "@/apis";
|
||||||
|
|
||||||
|
const OTA_MIN_BATTERY = 50;
|
||||||
|
const OTA_LOW_BATTERY_TEXT = "电量不足 50%,暂不支持 OTA 升级";
|
||||||
|
const OTA_OFFLINE_TEXT = "请先开启智能弓";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
type: String,
|
||||||
|
default: "new_version", // new_version | update_progress | update_success | update_failure
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
progress: {
|
||||||
|
type: Number,
|
||||||
|
default: 40,
|
||||||
|
},
|
||||||
|
// 副标题:如“新版本将优化智能弓体验”
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
// 详细说明:如“升级前请确保:...”
|
||||||
|
changelog: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
forceUpdate: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update", "skip", "close", "done", "retry"]);
|
||||||
|
|
||||||
|
const isNewVersion = computed(() => props.state === "new_version");
|
||||||
|
const isProgress = computed(() => props.state === "update_progress");
|
||||||
|
const isSuccess = computed(() => props.state === "update_success");
|
||||||
|
const isFailure = computed(() => props.state === "update_failure");
|
||||||
|
// Clamp progress to keep the progress bar width within its container.
|
||||||
|
const progressValue = computed(() => Math.min(100, Math.max(0, Number(props.progress) || 0)));
|
||||||
|
|
||||||
|
// 点击立即更新前先校验设备在线状态,再校验设备电量。
|
||||||
|
const handleUpdateClick = async () => {
|
||||||
|
try {
|
||||||
|
const deviceStatus = await getDeviceBatteryAPI();
|
||||||
|
if (deviceStatus?.online !== true) {
|
||||||
|
uni.showToast({
|
||||||
|
title: OTA_OFFLINE_TEXT,
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(deviceStatus?.battery) <= OTA_MIN_BATTERY) {
|
||||||
|
uni.showToast({
|
||||||
|
title: OTA_LOW_BATTERY_TEXT,
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
emit("update");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit("update");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view v-if="visible" class="ota-mask">
|
||||||
|
<!-- 图标 + 弹窗卡片 容器 -->
|
||||||
|
<view
|
||||||
|
class="ota-outer"
|
||||||
|
:class="isNewVersion ? 'outer-new' : 'outer-result'"
|
||||||
|
>
|
||||||
|
<!-- 悬浮图标(溢出卡片顶部) -->
|
||||||
|
<image
|
||||||
|
v-if="isNewVersion"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-mascot.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
class="float-icon float-mascot"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-else-if="isSuccess"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/ota/check-char.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
class="float-icon float-check"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-else-if="isFailure"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/ota/close-char.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
class="float-icon float-close"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-else-if="isProgress"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/ota/target-char.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
class="float-icon float-target"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 弹窗卡片:overflow:visible 允许按钮溢出底部,背景图通过 ota-bg-clip 独立裁剪保持圆角 -->
|
||||||
|
<view class="ota-dialog">
|
||||||
|
<view class="ota-bg-clip">
|
||||||
|
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-bg.png" mode="aspectFill" class="ota-bg" />
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="ota-content"
|
||||||
|
:class="{ 'content-new': isNewVersion, 'content-result': isProgress || isSuccess || isFailure }"
|
||||||
|
>
|
||||||
|
|
||||||
|
<!-- 发现新版本:new-ver.png 已包含标题图,不再重复文字;版本号使用 ota-ver.png 胶囊背景 -->
|
||||||
|
<block v-if="isNewVersion">
|
||||||
|
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/new-ver.png" mode="aspectFit" class="new-ver-img" />
|
||||||
|
<view v-if="version" class="version-tag-wrap">
|
||||||
|
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/ota-ver.png" mode="aspectFit" class="version-tag-bg-img" />
|
||||||
|
<text class="version-tag">{{ version }}</text>
|
||||||
|
</view>
|
||||||
|
<!-- 副标题:如“新版本将优化智能弓体验”,离下方详情 12rpx -->
|
||||||
|
<text v-if="description" class="desc-text">{{ description }}</text>
|
||||||
|
<!-- 详细说明:如“升级前请确保:...” -->
|
||||||
|
<text v-if="changelog" class="changelog-text">{{ changelog }}</text>
|
||||||
|
<view class="btn-group">
|
||||||
|
<view class="primary-btn" @click="handleUpdateClick">
|
||||||
|
<text class="primary-btn-text">立即更新</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="!forceUpdate" class="skip-text" @click="emit('skip')">暂不更新</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<!-- 更新成功:图片左边距 34rpx,文案左边距 44rpx,按钮浮动底部居中 -->
|
||||||
|
<block v-else-if="isSuccess">
|
||||||
|
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update-ok.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
|
||||||
|
<text class="dialog-desc">请关机并重启智能弓</text>
|
||||||
|
<view class="btn-group-result">
|
||||||
|
<view class="primary-btn" @click="emit('done')">
|
||||||
|
<text class="primary-btn-text">完成</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<!-- 更新中:复用成功标题图,正文区域展示进度条,无底部按钮 -->
|
||||||
|
<block v-else-if="isProgress">
|
||||||
|
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update_progress.png" mode="aspectFit" class="result-title-img" style="width: 220rpx; height: 62rpx;" />
|
||||||
|
<view class="progress-wrap">
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill" :style="{ width: `${progressValue}%` }"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<!-- 更新失败:图片左边距 34rpx,文案左对齐 44rpx,按钮浮动底部居中 -->
|
||||||
|
<block v-else-if="isFailure">
|
||||||
|
<image src="https://static.shelingxingqiu.com/shootmini/static/ota/update-fail.png" mode="aspectFit" class="result-title-img" style="width: 222rpx; height: 62rpx;" />
|
||||||
|
<text class="dialog-desc">请确保:</text>
|
||||||
|
<text class="dialog-desc">1、智能弓已开启</text>
|
||||||
|
<text class="dialog-desc">2、网路连接稳定</text>
|
||||||
|
<view class="btn-group-result">
|
||||||
|
<view class="primary-btn" @click="emit('retry')">
|
||||||
|
<text class="primary-btn-text">重试</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 关闭按钮(仅新版本状态,非强制更新时,位于弹窗下方) -->
|
||||||
|
<view
|
||||||
|
v-if="(isNewVersion || isFailure) && !forceUpdate"
|
||||||
|
class="ota-close-below"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<image src="../static/sicon/close.png" mode="aspectFit" style="width: 56rpx; height: 56rpx;" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ota-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 外层容器:相对定位,为浮动图标创造溢出空间 */
|
||||||
|
.ota-outer {
|
||||||
|
position: relative;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
/* 设计图:吉祥物向上突出弹窗顶部 40px(375px基准)× 2 = 80rpx */
|
||||||
|
.outer-new {
|
||||||
|
padding-top: 80rpx;
|
||||||
|
}
|
||||||
|
.outer-result {
|
||||||
|
padding-top: 80rpx;
|
||||||
|
padding-bottom: 66rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 浮动图标(绝对定位,位于卡片顶部上方) */
|
||||||
|
.float-icon {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
/* 吉祥物尺寸:设计图 149×109px(375px基准)× 2 = 298×218rpx */
|
||||||
|
.float-mascot {
|
||||||
|
width: 298rpx;
|
||||||
|
height: 218rpx;
|
||||||
|
top: -5px;
|
||||||
|
right: -74rpx;
|
||||||
|
}
|
||||||
|
.float-check {
|
||||||
|
width: 194rpx;
|
||||||
|
height: 166rpx;
|
||||||
|
top: 20px;
|
||||||
|
right: 30rpx;
|
||||||
|
}
|
||||||
|
.float-close {
|
||||||
|
width: 194rpx;
|
||||||
|
height: 164rpx;
|
||||||
|
top: 20px;
|
||||||
|
right: 30rpx;
|
||||||
|
}
|
||||||
|
.float-target {
|
||||||
|
width: 194rpx;
|
||||||
|
height: 166rpx;
|
||||||
|
top: 20px;
|
||||||
|
right: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗卡片:overflow:visible 允许按钮溢出底部,背景通过 ota-bg-clip 独立裁剪 */
|
||||||
|
.ota-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: 482rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
border: 2rpx solid #F9D5A1;
|
||||||
|
overflow: visible;
|
||||||
|
background-color: #392F1D;
|
||||||
|
}
|
||||||
|
/* 背景图裁剪层:独立 overflow:hidden + border-radius 保持圆角效果 */
|
||||||
|
.ota-bg-clip {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
.ota-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.ota-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* 按钮以外内容均左对齐 */
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.content-new {
|
||||||
|
padding: 30rpx 0 40rpx 0;
|
||||||
|
}
|
||||||
|
.content-result {
|
||||||
|
padding: 30rpx 0 66rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发现新版本内容 */
|
||||||
|
.new-ver-img {
|
||||||
|
width: 274rpx;
|
||||||
|
height: 62rpx;
|
||||||
|
/* 左边距 34rpx,去掉 margin-bottom */
|
||||||
|
margin-left: 34rpx;
|
||||||
|
}
|
||||||
|
/* 版本号胶囊容器:相对定位,使 ota-ver.png 作为背景衬底 */
|
||||||
|
.version-tag-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 116rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
/* 离标题图 -10rpx,左边距 50rpx,离下方副标题 22rpx */
|
||||||
|
margin-top: -10rpx;
|
||||||
|
margin-left: 50rpx;
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
/* ota-ver.png 胶囊背景图 */
|
||||||
|
.version-tag-bg-img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* 版本号文字:浮于背景图之上 */
|
||||||
|
.version-tag {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
color: rgba(254, 222, 100, 1);
|
||||||
|
font-size: 24rpx;
|
||||||
|
padding: 8rpx 22rpx 4rpx 24rpx;
|
||||||
|
}
|
||||||
|
/* 副标题(如"新版本将优化智能弓体验"):左边距 44rpx,离下方文案 12rpx */
|
||||||
|
.desc-text {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
line-height: 36rpx;
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 44rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
/* 详细说明文案(如“升级前请确保:...”):左边距 44rpx */
|
||||||
|
.changelog-text {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
line-height: 40rpx;
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 44rpx;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 按钮组(新版本状态):离上方文案 30rpx,内部按钮间距 24rpx */
|
||||||
|
.btn-group {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
/* 按钮组(结果状态):绝对定位,溢出卡片底边 -35rpx 悬浮在底边中间 */
|
||||||
|
.btn-group-result {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -35rpx;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
/* 主按钮:按照设计规范 width: 232rpx, height: 70rpx */
|
||||||
|
.primary-btn {
|
||||||
|
width: 232rpx;
|
||||||
|
height: 70rpx;
|
||||||
|
background-color: #FED847;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.primary-btn-text {
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #000000;
|
||||||
|
line-height: 36rpx;
|
||||||
|
}
|
||||||
|
/* 暂不更新:设计规范颜色 #5FADFF 蓝色 */
|
||||||
|
.skip-text {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #5FADFF;
|
||||||
|
line-height: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 更新结果内容:图片左边距 34rpx,下边距 16rpx */
|
||||||
|
.result-title-img {
|
||||||
|
margin-left: 34rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
/* 结果页文案:左对齐,左边距 44rpx,与 new_version 保持一致 */
|
||||||
|
.dialog-desc {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #FFFFFF;
|
||||||
|
line-height: 40rpx;
|
||||||
|
text-align: left;
|
||||||
|
margin-left: 44rpx;
|
||||||
|
}
|
||||||
|
.progress-wrap {
|
||||||
|
width: 394rpx;
|
||||||
|
margin-top: 40rpx;
|
||||||
|
margin-left: 44rpx;
|
||||||
|
}
|
||||||
|
.progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 18rpx;
|
||||||
|
background-color: rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: 999rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #FED847;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关闭按钮(位于弹窗下方) */
|
||||||
|
.ota-close-below {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,6 +15,19 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rowCount = new Array(6).fill(0);
|
const rowCount = new Array(6).fill(0);
|
||||||
|
|
||||||
|
const getRingText = (arrow) => {
|
||||||
|
if (!arrow) return "-";
|
||||||
|
if (arrow.ringX && arrow.ring) return "X环";
|
||||||
|
return `${Number(arrow.ring) || 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -31,27 +44,32 @@ const rowCount = new Array(6).fill(0);
|
|||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<image :src="player.avatar || '../static/user-icon.png'" mode="widthFix" />
|
<image :src="player.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||||
<text>{{ player.name }}</text>
|
<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>
|
<view>
|
||||||
<view v-for="(_, index) in rowCount" :key="index">
|
<view v-for="(_, index) in rowCount" :key="index">
|
||||||
<text>{{
|
<text>{{ getRingText(scores[0]?.[index]) }}</text>
|
||||||
scores[0] && scores[0][index] ? `${scores[0][index].ring}环` : "-"
|
|
||||||
}}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view>
|
<view>
|
||||||
<view v-for="(_, index) in rowCount" :key="index">
|
<view v-for="(_, index) in rowCount" :key="index">
|
||||||
<text>{{
|
<text>{{ getRingText(scores[1]?.[index]) }}</text>
|
||||||
scores[1] && scores[1][index] ? `${scores[1][index].ring}环` : "-"
|
|
||||||
}}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text
|
<text
|
||||||
>{{
|
>{{
|
||||||
scores
|
scores
|
||||||
.map((s) => s.reduce((last, next) => last + next.ring, 0))
|
.map((s) => (s || []).reduce((last, next) => last + next.ring, 0))
|
||||||
.reduce((last, next) => last + next, 0)
|
.reduce((last, next) => last + next, 0)
|
||||||
}}环</text
|
}}环</text
|
||||||
>
|
>
|
||||||
@@ -96,6 +114,13 @@ const rowCount = new Array(6).fill(0);
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
width: 20%;
|
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) {
|
.container > view:nth-child(4) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -20,13 +20,22 @@ const props = defineProps({
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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" : "",
|
||||||
|
];
|
||||||
|
|
||||||
const seats = new Array(props.total).fill(1);
|
const seats = new Array(props.total).fill(1);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="players">
|
<view class="players">
|
||||||
<view v-for="(_, index) in seats" :key="index">
|
<view v-for="(_, index) in seats" :key="index">
|
||||||
<image src="../static/player-bg.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/player-bg.png" mode="widthFix" />
|
||||||
<view v-if="players[index] && players[index].name" class="avatar">
|
<view v-if="players[index] && players[index].name" class="avatar">
|
||||||
<Avatar
|
<Avatar
|
||||||
:src="players[index].avatar || '../static/user-icon.png'"
|
:src="players[index].avatar || '../static/user-icon.png'"
|
||||||
@@ -40,10 +49,27 @@ const seats = new Array(props.total).fill(1);
|
|||||||
<view v-else class="player-unknow">
|
<view v-else class="player-unknow">
|
||||||
<image src="../static/question-mark.png" mode="widthFix" />
|
<image src="../static/question-mark.png" mode="widthFix" />
|
||||||
</view>
|
</view>
|
||||||
<text v-if="players[index] && players[index].name">{{
|
<view
|
||||||
players[index].name
|
v-if="players[index] && players[index].name && isMember(players[index])"
|
||||||
}}</text>
|
:class="['player-seat-name', ...getMemberNicknameClass(players[index])]"
|
||||||
<text v-else :style="{ color: '#fff9' }">虚位以待</text>
|
>
|
||||||
|
<text class="member-nickname__text">{{ players[index].name }}</text>
|
||||||
|
<text
|
||||||
|
v-if="players[index].sVip === true"
|
||||||
|
class="member-nickname__shine"
|
||||||
|
>
|
||||||
|
{{ players[index].name }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text
|
||||||
|
v-else-if="players[index] && players[index].name"
|
||||||
|
class="player-seat-name"
|
||||||
|
>
|
||||||
|
{{ players[index].name }}
|
||||||
|
</text>
|
||||||
|
<text v-else class="player-seat-name" :style="{ color: '#fff9' }">
|
||||||
|
虚位以待
|
||||||
|
</text>
|
||||||
<view v-if="index === 0" class="founder">管理员</view>
|
<view v-if="index === 0" class="founder">管理员</view>
|
||||||
<!-- <image
|
<!-- <image
|
||||||
:src="`../static/player-${index + 1}.png`"
|
:src="`../static/player-${index + 1}.png`"
|
||||||
@@ -108,7 +134,7 @@ const seats = new Array(props.total).fill(1);
|
|||||||
height: 28rpx;
|
height: 28rpx;
|
||||||
line-height: 28rpx;
|
line-height: 28rpx;
|
||||||
}
|
}
|
||||||
.players > view > text:nth-child(3) {
|
.player-seat-name {
|
||||||
width: 20vw;
|
width: 20vw;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ const props = defineProps({
|
|||||||
const like = ref(props.data.ifLike);
|
const like = ref(props.data.ifLike);
|
||||||
const likeCount = ref(props.data.likeTotal || 0);
|
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(
|
watch(
|
||||||
() => props.data,
|
() => props.data,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
@@ -53,7 +62,13 @@ const onClick = async () => {
|
|||||||
<view>
|
<view>
|
||||||
<Avatar :src="data.avatar || '../static/user-icon.png'" :size="36" />
|
<Avatar :src="data.avatar || '../static/user-icon.png'" :size="36" />
|
||||||
<view>
|
<view>
|
||||||
<text class="truncate">{{ data.name }}</text>
|
<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>
|
<view>
|
||||||
<text>{{ data.totalDay }}天</text>
|
<text>{{ data.totalDay }}天</text>
|
||||||
<view />
|
<view />
|
||||||
@@ -118,6 +133,14 @@ const onClick = async () => {
|
|||||||
color: #333333;
|
color: #333333;
|
||||||
margin-bottom: 5rpx;
|
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 {
|
.rank-item > view:nth-child(2) > view:last-child > view {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ onMounted(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="right-part">
|
<view class="right-part">
|
||||||
<image src="../static/bow-target.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/bow-target.png" mode="widthFix" />
|
||||||
<view class="arrow-amount">
|
<view class="arrow-amount">
|
||||||
<text>{{ data.actualTotalRing }}</text>
|
<text>{{ data.actualTotalRing }}</text>
|
||||||
<text>/</text>
|
<text>/</text>
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<image src="../static/donate.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/donate.png" mode="widthFix" />
|
||||||
<text>感谢您对我们公益项目的支持!</text>
|
<text>感谢您对我们公益项目的支持!</text>
|
||||||
<view class="amounts">
|
<view class="amounts">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
import { ref, watch } from "vue";
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
rowCount: {
|
rowCount: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -26,34 +26,27 @@ const items = ref(new Array(props.total).fill(9));
|
|||||||
const width = ref(92);
|
const width = ref(92);
|
||||||
const itemWidth = ref(0);
|
const itemWidth = ref(0);
|
||||||
const bgImages = [
|
const bgImages = [
|
||||||
"../static/complete-light1.png",
|
"https://static.shelingxingqiu.com/shootmini/static/complete-light1.png",
|
||||||
"../static/complete-light2.png",
|
"https://static.shelingxingqiu.com/shootmini/static/complete-light2.png",
|
||||||
];
|
];
|
||||||
const bgIndex = ref(0);
|
|
||||||
watch(
|
watch(
|
||||||
() => props.total,
|
() => props.total,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
items.value = new Array(newValue).fill(9);
|
items.value = new Array(newValue).fill(9);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const timer = ref(null);
|
|
||||||
onMounted(() => {
|
|
||||||
timer.value = setInterval(() => {
|
|
||||||
bgIndex.value = bgIndex.value === 0 ? 1 : 0;
|
|
||||||
}, 200);
|
|
||||||
});
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (timer.value) {
|
|
||||||
clearInterval(timer.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
|
<template v-if="total > 0 && arrows.length === total && completeEffect">
|
||||||
<image
|
<image
|
||||||
v-if="total > 0 && arrows.length === total && completeEffect"
|
v-for="(image, index) in bgImages"
|
||||||
:src="bgImages[bgIndex]"
|
:key="image"
|
||||||
class="complete-light"
|
:src="image"
|
||||||
|
:class="[
|
||||||
|
'complete-light',
|
||||||
|
index === 0 ? 'complete-light--first' : 'complete-light--second',
|
||||||
|
]"
|
||||||
:style="{
|
:style="{
|
||||||
width: `calc(${(100 / (rowCount + 2)) * rowCount}vw + ${
|
width: `calc(${(100 / (rowCount + 2)) * rowCount}vw + ${
|
||||||
(100 / (total * 2)) * (rowCount * 2 + (total === 12 ? 8 : 24))
|
(100 / (total * 2)) * (rowCount * 2 + (total === 12 ? 8 : 24))
|
||||||
@@ -65,6 +58,7 @@ onBeforeUnmount(() => {
|
|||||||
top: `${total === 12 ? -2 : -3}vw`,
|
top: `${total === 12 ? -2 : -3}vw`,
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
<view
|
<view
|
||||||
v-for="(_, index) in items"
|
v-for="(_, index) in items"
|
||||||
:key="index"
|
:key="index"
|
||||||
@@ -121,4 +115,30 @@ onBeforeUnmount(() => {
|
|||||||
.complete-light {
|
.complete-light {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
.complete-light--first {
|
||||||
|
animation: complete-light-first 400ms steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
.complete-light--second {
|
||||||
|
animation: complete-light-second 400ms steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
@keyframes complete-light-first {
|
||||||
|
0%,
|
||||||
|
49.9% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes complete-light-second {
|
||||||
|
0%,
|
||||||
|
49.9% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,13 +5,19 @@ const props = defineProps({
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const getSum = (a, b, c) => {
|
const getSum = (...arrows) => {
|
||||||
const sum = (Number(a) || 0) + (Number(b) || 0) + (Number(c) || 0);
|
const recordedArrows = arrows.filter(Boolean);
|
||||||
return sum > 0 ? sum + "环" : "-";
|
if (!recordedArrows.length) return "-";
|
||||||
|
const sum = recordedArrows.reduce(
|
||||||
|
(total, arrow) => total + (Number(arrow.ring) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return `${sum}环`;
|
||||||
};
|
};
|
||||||
const roundsName = ["第一轮", "第二轮", "第三轮", "第四轮"];
|
const roundsName = ["第一轮", "第二轮", "第三轮", "第四轮"];
|
||||||
const getShowText = (arrow = {}) => {
|
const getShowText = (arrow) => {
|
||||||
return arrow.ring ? (arrow.ringX ? "X" : arrow.ring + "环") : "-";
|
if (!arrow) return "-";
|
||||||
|
return arrow.ringX ? "X" : `${Number(arrow.ring) || 0}环`;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -57,26 +57,34 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getRing = (arrow) => {
|
const getRing = (arrow) => {
|
||||||
|
if (!arrow) return "-";
|
||||||
if (arrow.ringX) return "X";
|
if (arrow.ringX) return "X";
|
||||||
return arrow.ring ? arrow.ring : "-";
|
return Number(arrow.ring) || 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const arrows = computed(() => {
|
const arrows = computed(() => {
|
||||||
const data = new Array(props.total).fill({ ring: 0 });
|
const data = new Array(props.total).fill(null);
|
||||||
(props.result.details || []).forEach((arrow, index) => {
|
(props.result.details || []).forEach((arrow, index) => {
|
||||||
data[index] = arrow;
|
data[index] = arrow;
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
});
|
});
|
||||||
|
|
||||||
const validArrows = computed(() => arrows.value.filter((a) => !!a.ring).length);
|
const validArrows = computed(
|
||||||
|
() => arrows.value.filter((a) => Number(a?.ring) > 0).length
|
||||||
|
);
|
||||||
|
const isMember = computed(() => user.value.vip === true || user.value.sVip === true);
|
||||||
|
const openCoachComment = () => {
|
||||||
|
if (!isMember.value) return;
|
||||||
|
showComment.value = true;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<view :class="['container-header', showPanel ? 'scale-in' : 'scale-out']">
|
<view :class="['container-header', showPanel ? 'scale-in' : 'scale-out']">
|
||||||
<image :src="tipSrc" mode="widthFix" />
|
<image :src="tipSrc" mode="widthFix" />
|
||||||
<image src="../static/finish-frame.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/finish-frame.png" mode="widthFix" />
|
||||||
<text
|
<text
|
||||||
>完成<text class="gold-text">{{ validArrows }}</text
|
>完成<text class="gold-text">{{ validArrows }}</text
|
||||||
>箭,获得<text class="gold-text">{{ validArrows }}</text
|
>箭,获得<text class="gold-text">{{ validArrows }}</text
|
||||||
@@ -112,9 +120,10 @@ const validArrows = computed(() => arrows.value.filter((a) => !!a.ring).length);
|
|||||||
:onClick="onClickShare"
|
:onClick="onClickShare"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
v-if="isMember"
|
||||||
name="教练点评"
|
name="教练点评"
|
||||||
src="../static/review.png"
|
src="../static/review.png"
|
||||||
:onClick="() => (showComment = true)"
|
:onClick="openCoachComment"
|
||||||
/>
|
/>
|
||||||
</block>
|
</block>
|
||||||
<SButton
|
<SButton
|
||||||
@@ -137,7 +146,7 @@ const validArrows = computed(() => arrows.value.filter((a) => !!a.ring).length);
|
|||||||
}}</text
|
}}</text
|
||||||
>环的成绩,所有箭支上靶后的平均点间距为<text
|
>环的成绩,所有箭支上靶后的平均点间距为<text
|
||||||
:style="{ color: '#fed847' }"
|
:style="{ color: '#fed847' }"
|
||||||
>{{ Number((result.average_distance || 0).toFixed(2)) }}</text
|
>{{ Number((result?.interpretation?.spreadStability || 0).toFixed(2)) }}</text
|
||||||
>,{{
|
>,{{
|
||||||
result.spreadEvaluation === "Dispersed"
|
result.spreadEvaluation === "Dispersed"
|
||||||
? "还需要持续改进哦~"
|
? "还需要持续改进哦~"
|
||||||
|
|||||||
@@ -27,22 +27,22 @@ const getContentHeight = () => {
|
|||||||
<view class="scale-in" :style="{ height: getContentHeight() }">
|
<view class="scale-in" :style="{ height: getContentHeight() }">
|
||||||
<image
|
<image
|
||||||
v-if="mode === 'normal'"
|
v-if="mode === 'normal'"
|
||||||
src="../static/screen-hint-bg.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/screen-hint-bg.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<image
|
<image
|
||||||
v-if="mode === 'tall'"
|
v-if="mode === 'tall'"
|
||||||
src="../static/coach-comment.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/coach-comment.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<image
|
<image
|
||||||
v-if="mode === 'square'"
|
v-if="mode === 'square'"
|
||||||
src="../static/prompt-bg-square.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/prompt-bg-square.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<image
|
<image
|
||||||
v-if="mode === 'small'"
|
v-if="mode === 'small'"
|
||||||
src="../static/finish-frame.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/finish-frame.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const props = defineProps({
|
|||||||
<template>
|
<template>
|
||||||
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
|
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
|
||||||
<view class="scale-in">
|
<view class="scale-in">
|
||||||
<image src="../static/point-book-tip-bg.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/point-book-tip-bg.png" mode="widthFix" />
|
||||||
<slot />
|
<slot />
|
||||||
</view>
|
</view>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||||
import audioManager from "@/audioManager";
|
import audioManager from "@/audioManager";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
import { getDirectionText } from "@/util";
|
import {
|
||||||
|
getDirectionText,
|
||||||
|
getInvalidShotAudioKey,
|
||||||
|
getInvalidShotText,
|
||||||
|
} from "@/util";
|
||||||
|
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
@@ -38,10 +42,18 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
halfRest: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
onStop: {
|
onStop: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
endAudioKey: {
|
||||||
|
type: String,
|
||||||
|
default: "比赛结束",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const barColor = ref("#fed847");
|
const barColor = ref("#fed847");
|
||||||
@@ -136,32 +148,37 @@ const updateSound = () => {
|
|||||||
async function onReceiveMessage(msg) {
|
async function onReceiveMessage(msg) {
|
||||||
if (Array.isArray(msg)) return;
|
if (Array.isArray(msg)) return;
|
||||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||||
|
const audioKey = props.melee && (halfTime.value || props.halfRest) ? "下半场开始" : "比赛开始";
|
||||||
halfTime.value = false;
|
halfTime.value = false;
|
||||||
audioManager.play("比赛开始");
|
audioManager.play(audioKey);
|
||||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||||
audioManager.play("比赛结束", false);
|
audioManager.play(props.endAudioKey, false);
|
||||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||||
let arrow = {};
|
let arrow = {};
|
||||||
if (msg.details && Array.isArray(msg.details)) {
|
if (msg.details && Array.isArray(msg.details)) {
|
||||||
arrow = msg.details[msg.details.length - 1];
|
arrow = msg.details[msg.details.length - 1];
|
||||||
} else {
|
} else {
|
||||||
if (msg.shootData.playerId !== user.value.id) return;
|
if (!msg.shootData || String(msg.shootData.playerId) !== String(user.value.id)) return;
|
||||||
if (msg.shootData) arrow = msg.shootData;
|
if (msg.shootData) arrow = msg.shootData;
|
||||||
}
|
}
|
||||||
let key = [];
|
let key = [];
|
||||||
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶");
|
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶");
|
||||||
if (arrow.angle !== null)
|
if (arrow.angle)
|
||||||
key.push(`向${getDirectionText(arrow.angle)}调整`);
|
key.push(`向${getDirectionText(arrow.angle)}调整`);
|
||||||
|
const shouldPlayTententen =
|
||||||
|
arrow.threeConsecutive10Rings === true ||
|
||||||
|
msg.shootData?.threeConsecutive10Rings === true;
|
||||||
|
if (!props.melee && shouldPlayTententen) key.push("tententen");
|
||||||
audioManager.play(key, false);
|
audioManager.play(key, false);
|
||||||
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
|
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
|
||||||
halfTime.value = true;
|
halfTime.value = true;
|
||||||
audioManager.play("中场休息");
|
audioManager.play("中场休息");
|
||||||
} else if (msg.type === MESSAGETYPESV2.InvalidShot) {
|
} else if (msg.type === MESSAGETYPESV2.InvalidShot) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: "距离不足,无效",
|
title: getInvalidShotText(msg.shootData),
|
||||||
icon: "none",
|
icon: "none",
|
||||||
});
|
});
|
||||||
audioManager.play("射击无效");
|
audioManager.play(getInvalidShotAudioKey(msg.shootData));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +203,7 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<view class="container" :style="{ display: show ? 'block' : 'none' }">
|
<view class="container" :style="{ display: show ? 'block' : 'none' }">
|
||||||
<view>
|
<view>
|
||||||
<image src="../static/shooter.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/shooter.png" mode="widthFix" />
|
||||||
<text>{{ tipContent }}</text>
|
<text>{{ tipContent }}</text>
|
||||||
<button hover-class="none" @click="updateSound">
|
<button hover-class="none" @click="updateSound">
|
||||||
<image
|
<image
|
||||||
|
|||||||
+33
-13
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { onShow } from "@dcloudio/uni-app";
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
import SModal from "@/components/SModal.vue";
|
import SModal from "@/components/SModal.vue";
|
||||||
import Avatar from "@/components/Avatar.vue";
|
import Avatar from "@/components/Avatar.vue";
|
||||||
@@ -11,12 +11,13 @@ import {
|
|||||||
loginAPI,
|
loginAPI,
|
||||||
getHomeData,
|
getHomeData,
|
||||||
getPhoneNumberAPI,
|
getPhoneNumberAPI,
|
||||||
|
getPhoneNumberAPIv2,
|
||||||
getDeviceBatteryAPI,
|
getDeviceBatteryAPI,
|
||||||
} from "@/apis";
|
} from "@/apis";
|
||||||
|
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { updateUser, updateDevice, updateOnline } = store;
|
const { updateUser, updateDevice, updateOnline, clearDevice } = store;
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
@@ -31,6 +32,10 @@ const props = defineProps({
|
|||||||
type: Function,
|
type: Function,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
onSuccess: {
|
||||||
|
type: Function,
|
||||||
|
default: () => {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const agree = ref(false);
|
const agree = ref(false);
|
||||||
const phone = ref("");
|
const phone = ref("");
|
||||||
@@ -43,12 +48,12 @@ const handleAgree = () => {
|
|||||||
|
|
||||||
async function getphonenumber(e) {
|
async function getphonenumber(e) {
|
||||||
if (e.detail.code) {
|
if (e.detail.code) {
|
||||||
const wxResult = await wxLogin();
|
// const wxResult = await wxLogin();
|
||||||
const result = await getPhoneNumberAPI({
|
const result = await getPhoneNumberAPIv2({
|
||||||
...e.detail,
|
// ...e.detail,
|
||||||
code: wxResult.code,
|
code: e.detail.code,
|
||||||
});
|
});
|
||||||
if (result.phone) phone.value = result.phone;
|
if (result.purePhoneNumber) phone.value = result.purePhoneNumber;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +65,21 @@ function onNicknameChange(e) {
|
|||||||
nickName.value = e.detail.value;
|
nickName.value = e.detail.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
loading.value = false;
|
||||||
|
agree.value = false;
|
||||||
|
phone.value = "";
|
||||||
|
avatarUrl.value = "";
|
||||||
|
nickName.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(show) => {
|
||||||
|
if (show) resetForm();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
if (!phone.value) {
|
if (!phone.value) {
|
||||||
@@ -107,8 +127,11 @@ async function doLogin() {
|
|||||||
);
|
);
|
||||||
const data = await getDeviceBatteryAPI();
|
const data = await getDeviceBatteryAPI();
|
||||||
updateOnline(data.online);
|
updateOnline(data.online);
|
||||||
|
} else {
|
||||||
|
clearDevice();
|
||||||
}
|
}
|
||||||
props.onClose();
|
props.onClose();
|
||||||
|
await props.onSuccess();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("login error", error);
|
console.log("login error", error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,11 +160,7 @@ const openPrivacyLink = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
loading.value = false;
|
resetForm();
|
||||||
agree.value = false;
|
|
||||||
phone.value = "";
|
|
||||||
avatarUrl.value = "";
|
|
||||||
nickName.value = "";
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -187,10 +206,11 @@ onShow(() => {
|
|||||||
<text :style="{ color: noBg ? '#666' : '#fff' }">昵称:</text>
|
<text :style="{ color: noBg ? '#666' : '#fff' }">昵称:</text>
|
||||||
<input
|
<input
|
||||||
type="nickname"
|
type="nickname"
|
||||||
|
:value="nickName"
|
||||||
placeholder="请输入昵称"
|
placeholder="请输入昵称"
|
||||||
:placeholder-style="`color: ${noBg ? '#666' : '#fff9'} `"
|
:placeholder-style="`color: ${noBg ? '#666' : '#fff9'} `"
|
||||||
|
@input="onNicknameChange"
|
||||||
@change="onNicknameChange"
|
@change="onNicknameChange"
|
||||||
@blur="onNicknameBlur"
|
|
||||||
:style="{ color: noBg ? '#333' : '#fff' }"
|
:style="{ color: noBg ? '#333' : '#fff' }"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
interval: {
|
interval: {
|
||||||
@@ -14,13 +14,24 @@ const props = defineProps({
|
|||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
current: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
onChange: {
|
onChange: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: (index) => {},
|
default: (index) => {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentIndex = ref(0);
|
const currentIndex = ref(props.current);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.current,
|
||||||
|
(index) => {
|
||||||
|
currentIndex.value = index;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const handleChange = (e) => {
|
const handleChange = (e) => {
|
||||||
currentIndex.value = e.detail.current;
|
currentIndex.value = e.detail.current;
|
||||||
@@ -75,7 +86,7 @@ const handleChange = (e) => {
|
|||||||
|
|
||||||
.dots {
|
.dots {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 5%;
|
bottom: 2%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,652 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
getCurrentInstance,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
const defaultCanvasSize = 300;
|
||||||
|
const defaultRingCount = 10;
|
||||||
|
const highlightRevealProgressFrames = [0.08, 0.2, 0.38, 0.6, 0.82, 1];
|
||||||
|
const highlightRevealFrameInterval = 60;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
// canvas 唯一标识;不传时组件内部自动生成,避免多个靶面 canvas-id 冲突。
|
||||||
|
canvasId: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
// 业务坐标半径,例如 20 表示命中点坐标范围为 -20 到 20。
|
||||||
|
// 当前组件主要用它参与重绘判断,外层命中点定位也应使用同一半径。
|
||||||
|
coordinateRadius: {
|
||||||
|
type: Number,
|
||||||
|
default: 20,
|
||||||
|
},
|
||||||
|
// 是否显示靶心十字辅助线。
|
||||||
|
showCrosshair: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
// 是否显示环数文字。
|
||||||
|
showRingLabels: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
// 从正上方开始顺时针等分的区域数量。
|
||||||
|
sectorCount: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
// 当前高亮区域,范围为 1 到 sectorCount。
|
||||||
|
activeSector: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
// 指定环数,1 到 10;无效值表示高亮整个区域。
|
||||||
|
activeRing: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
// 每次变化时以固定低帧数重新展开当前高亮扇区;默认关闭。
|
||||||
|
highlightRefreshToken: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
showSectorLabels: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
// 只绘制透明高亮层,不绘制完整靶纸;用于叠加在靶纸图片上。
|
||||||
|
highlightOnly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
// 外部指定 canvas 绘制尺寸;用于让高亮层跟随靶图真实显示区域。
|
||||||
|
canvasWidth: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
canvasHeight: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
// 靶纸样式覆盖配置,例如环数、环色、环线颜色、环数字体等。
|
||||||
|
targetStyleConfig: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
// 十字辅助线样式覆盖配置。
|
||||||
|
crosshairStyle: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
// 区域分割线样式覆盖配置。
|
||||||
|
sectorStyle: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
// 区域数字样式覆盖配置。
|
||||||
|
sectorLabelStyle: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
// 高亮样式覆盖配置。
|
||||||
|
highlightStyle: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
const localCanvasId = `target-canvas-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const currentCanvasId = computed(() => props.canvasId || localCanvasId);
|
||||||
|
const lastDrawKey = ref("");
|
||||||
|
const canvasSize = ref({
|
||||||
|
width: defaultCanvasSize,
|
||||||
|
height: defaultCanvasSize,
|
||||||
|
});
|
||||||
|
let drawRequestGeneration = 0;
|
||||||
|
let highlightAnimationGeneration = 0;
|
||||||
|
let highlightAnimationTimer = null;
|
||||||
|
let mountDrawTimer = null;
|
||||||
|
|
||||||
|
// 完整靶纸默认样式,调用方可以通过 targetStyleConfig 局部覆盖。
|
||||||
|
const defaultTargetStyleConfig = {
|
||||||
|
ringCount: defaultRingCount,
|
||||||
|
ringColors: {
|
||||||
|
1: "#f8f8f3",
|
||||||
|
2: "#f8f8f3",
|
||||||
|
3: "#595959",
|
||||||
|
4: "#595959",
|
||||||
|
5: "#24aee0",
|
||||||
|
6: "#24aee0",
|
||||||
|
7: "#ff1f35",
|
||||||
|
8: "#ff1f35",
|
||||||
|
9: "#f7d34a",
|
||||||
|
10: "#f7d34a",
|
||||||
|
},
|
||||||
|
ringLineColor: "rgba(150, 150, 150, 0.55)",
|
||||||
|
ringLineWidthRatio: 0.0022,
|
||||||
|
centerDotColor: "#ffffff",
|
||||||
|
centerDotRadiusRatio: 0.0048,
|
||||||
|
ringLabelFontRatio: 0.032,
|
||||||
|
ringLabelDarkColor: "#111111",
|
||||||
|
ringLabelLightColor: "#ffffff",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 十字辅助线默认样式。
|
||||||
|
const defaultCrosshairStyle = {
|
||||||
|
color: "rgba(20, 20, 20, 0.38)",
|
||||||
|
lineWidthRatio: 0.0025,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 顺时针等分线默认样式。
|
||||||
|
const defaultSectorStyle = {
|
||||||
|
color: "rgba(255, 255, 255, 0.82)",
|
||||||
|
lineWidthRatio: 0.004,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 区域数字默认样式。
|
||||||
|
const defaultSectorLabelStyle = {
|
||||||
|
color: "#ffffff",
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.62)",
|
||||||
|
fontSizeRatio: 0.075,
|
||||||
|
radiusRatio: 0.76,
|
||||||
|
badgeRadiusRatio: 0.07,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 高亮区域默认样式。
|
||||||
|
const defaultHighlightStyle = {
|
||||||
|
color: "rgba(255, 228, 0, 0.6)",
|
||||||
|
strokeColor: "rgba(254, 216, 71, 0.82)",
|
||||||
|
lineWidthRatio: 0.003,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 合并默认靶纸样式和外部传入样式,ringColors 单独深合并。
|
||||||
|
const mergeTargetStyleConfig = () => ({
|
||||||
|
...defaultTargetStyleConfig,
|
||||||
|
...props.targetStyleConfig,
|
||||||
|
ringColors: {
|
||||||
|
...defaultTargetStyleConfig.ringColors,
|
||||||
|
...(props.targetStyleConfig?.ringColors || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统一把外部传入值转成有效数字,非法值使用 fallback。
|
||||||
|
const getNumber = (value, fallback = 0) => {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取指定环数的填充色,兼容数字 key 和字符串 key。
|
||||||
|
const getRingColor = (ring, config) => {
|
||||||
|
return config.ringColors?.[ring] || config.ringColors?.[String(ring)] || "#ffffff";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPositiveInteger = (value) => {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 正上方作为第一区起始边界,Canvas 角度递增方向即为顺时针。
|
||||||
|
const getSectorAngles = (sector, sectorCount) => {
|
||||||
|
const count = getPositiveInteger(sectorCount);
|
||||||
|
const index = getPositiveInteger(sector);
|
||||||
|
if (!count || !index || index > count) return null;
|
||||||
|
|
||||||
|
const step = (Math.PI * 2) / count;
|
||||||
|
const startAngle = -Math.PI / 2 + (index - 1) * step;
|
||||||
|
return {
|
||||||
|
startAngle,
|
||||||
|
endAngle: startAngle + step,
|
||||||
|
middleAngle: startAngle + step / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 绘制实心圆,靶纸环区和中心点都会用到。
|
||||||
|
const drawCircle = (ctx, centerX, centerY, radius, fillColor) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||||
|
ctx.setFillStyle(fillColor);
|
||||||
|
ctx.fill();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 绘制环形扇区,用于按象限高亮指定环数。
|
||||||
|
const drawAnnularSector = (
|
||||||
|
ctx,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
innerRadius,
|
||||||
|
outerRadius,
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
fillColor,
|
||||||
|
strokeColor = "",
|
||||||
|
lineWidth = 0
|
||||||
|
) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, outerRadius, startAngle, endAngle);
|
||||||
|
|
||||||
|
if (innerRadius > 0) {
|
||||||
|
ctx.arc(centerX, centerY, innerRadius, endAngle, startAngle, true);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(centerX, centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.setFillStyle(fillColor);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
if (strokeColor && lineWidth > 0) {
|
||||||
|
ctx.setStrokeStyle(strokeColor);
|
||||||
|
ctx.setLineWidth(lineWidth);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从外到内绘制完整靶纸色环。
|
||||||
|
const drawTargetRings = (ctx, centerX, centerY, targetRadius, config) => {
|
||||||
|
for (let ring = 1; ring <= config.ringCount; ring += 1) {
|
||||||
|
const radius = targetRadius * ((config.ringCount + 1 - ring) / config.ringCount);
|
||||||
|
drawCircle(ctx, centerX, centerY, radius, getRingColor(ring, config));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 高亮后端指定区域;activeRing 有效时只高亮该区域内的单个环。
|
||||||
|
const drawSectorHighlight = (
|
||||||
|
ctx,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
targetRadius,
|
||||||
|
config,
|
||||||
|
revealProgress = 1
|
||||||
|
) => {
|
||||||
|
const angles = getSectorAngles(props.activeSector, props.sectorCount);
|
||||||
|
if (!angles) return;
|
||||||
|
|
||||||
|
const safeRevealProgress = Math.min(
|
||||||
|
Math.max(getNumber(revealProgress, 1), 0),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
if (safeRevealProgress <= 0) return;
|
||||||
|
|
||||||
|
const ring = getPositiveInteger(props.activeRing);
|
||||||
|
const hasActiveRing = ring >= 1 && ring <= config.ringCount;
|
||||||
|
const innerRadius = hasActiveRing
|
||||||
|
? targetRadius * ((config.ringCount - ring) / config.ringCount)
|
||||||
|
: 0;
|
||||||
|
const outerRadius = hasActiveRing
|
||||||
|
? targetRadius * ((config.ringCount + 1 - ring) / config.ringCount)
|
||||||
|
: targetRadius;
|
||||||
|
const style = {
|
||||||
|
...defaultHighlightStyle,
|
||||||
|
...props.highlightStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
drawAnnularSector(
|
||||||
|
ctx,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
innerRadius,
|
||||||
|
outerRadius,
|
||||||
|
angles.startAngle,
|
||||||
|
angles.startAngle +
|
||||||
|
(angles.endAngle - angles.startAngle) * safeRevealProgress,
|
||||||
|
style.color,
|
||||||
|
style.strokeColor,
|
||||||
|
Math.max(1, targetRadius * style.lineWidthRatio)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 从正上方开始顺时针绘制所有区域边界。
|
||||||
|
const drawSectorLines = (ctx, centerX, centerY, targetRadius) => {
|
||||||
|
const count = getPositiveInteger(props.sectorCount);
|
||||||
|
if (!count) return;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
...defaultSectorStyle,
|
||||||
|
...props.sectorStyle,
|
||||||
|
};
|
||||||
|
const step = (Math.PI * 2) / count;
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const angle = -Math.PI / 2 + index * step;
|
||||||
|
ctx.moveTo(centerX, centerY);
|
||||||
|
ctx.lineTo(
|
||||||
|
centerX + Math.cos(angle) * targetRadius,
|
||||||
|
centerY + Math.sin(angle) * targetRadius
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ctx.setStrokeStyle(style.color);
|
||||||
|
ctx.setLineWidth(Math.max(1, targetRadius * style.lineWidthRatio));
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 绘制各环之间的分割线。
|
||||||
|
const drawRingLines = (ctx, centerX, centerY, targetRadius, config) => {
|
||||||
|
const lineWidth = Math.max(1, targetRadius * config.ringLineWidthRatio);
|
||||||
|
|
||||||
|
ctx.setStrokeStyle(config.ringLineColor);
|
||||||
|
ctx.setLineWidth(lineWidth);
|
||||||
|
|
||||||
|
for (let index = 1; index <= config.ringCount; index += 1) {
|
||||||
|
const radius = targetRadius * (index / config.ringCount);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(centerX, centerY, radius, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 绘制靶心十字辅助线。
|
||||||
|
const drawCrosshair = (ctx, centerX, centerY, targetRadius) => {
|
||||||
|
if (!props.showCrosshair) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
...defaultCrosshairStyle,
|
||||||
|
...props.crosshairStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(centerX - targetRadius, centerY);
|
||||||
|
ctx.lineTo(centerX + targetRadius, centerY);
|
||||||
|
ctx.moveTo(centerX, centerY - targetRadius);
|
||||||
|
ctx.lineTo(centerX, centerY + targetRadius);
|
||||||
|
ctx.setStrokeStyle(style.color);
|
||||||
|
ctx.setLineWidth(Math.max(1, targetRadius * style.lineWidthRatio));
|
||||||
|
ctx.stroke();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 绘制环数文字。
|
||||||
|
const drawRingLabels = (ctx, centerX, centerY, targetRadius, config) => {
|
||||||
|
if (!props.showRingLabels) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ringWidth = targetRadius / config.ringCount;
|
||||||
|
const fontSize = Math.max(10, targetRadius * config.ringLabelFontRatio);
|
||||||
|
|
||||||
|
ctx.setFontSize(fontSize);
|
||||||
|
ctx.setTextAlign("center");
|
||||||
|
ctx.setTextBaseline("middle");
|
||||||
|
|
||||||
|
for (let ring = config.ringCount; ring >= 1; ring -= 1) {
|
||||||
|
const y = centerY + (config.ringCount - ring + 0.45) * ringWidth;
|
||||||
|
const color = ring <= 2 ? config.ringLabelDarkColor : config.ringLabelLightColor;
|
||||||
|
ctx.setFillStyle(color);
|
||||||
|
ctx.fillText(String(ring), centerX, y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在每个区域中线位置绘制编号,编号层始终位于高亮和分割线之上。
|
||||||
|
const drawSectorLabels = (ctx, centerX, centerY, targetRadius) => {
|
||||||
|
const count = getPositiveInteger(props.sectorCount);
|
||||||
|
if (!props.showSectorLabels || !count) return;
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
...defaultSectorLabelStyle,
|
||||||
|
...props.sectorLabelStyle,
|
||||||
|
};
|
||||||
|
const labelRadius = targetRadius * style.radiusRatio;
|
||||||
|
const badgeRadius = Math.max(10, targetRadius * style.badgeRadiusRatio);
|
||||||
|
|
||||||
|
ctx.setFontSize(Math.max(11, targetRadius * style.fontSizeRatio));
|
||||||
|
ctx.setTextAlign("center");
|
||||||
|
ctx.setTextBaseline("middle");
|
||||||
|
|
||||||
|
for (let sector = 1; sector <= count; sector += 1) {
|
||||||
|
const angles = getSectorAngles(sector, count);
|
||||||
|
const x = centerX + Math.cos(angles.middleAngle) * labelRadius;
|
||||||
|
const y = centerY + Math.sin(angles.middleAngle) * labelRadius;
|
||||||
|
drawCircle(ctx, x, y, badgeRadius, style.backgroundColor);
|
||||||
|
ctx.setFillStyle(style.color);
|
||||||
|
ctx.fillText(String(sector), x, y);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成本次绘制状态的唯一 key,用于避免相同内容重复 draw。
|
||||||
|
const getDrawKey = (width, height) => {
|
||||||
|
return JSON.stringify({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
coordinateRadius: props.coordinateRadius,
|
||||||
|
showCrosshair: props.showCrosshair,
|
||||||
|
showRingLabels: props.showRingLabels,
|
||||||
|
sectorCount: props.sectorCount,
|
||||||
|
activeSector: props.activeSector,
|
||||||
|
activeRing: props.activeRing,
|
||||||
|
showSectorLabels: props.showSectorLabels,
|
||||||
|
targetStyleConfig: props.targetStyleConfig,
|
||||||
|
crosshairStyle: props.crosshairStyle,
|
||||||
|
sectorStyle: props.sectorStyle,
|
||||||
|
sectorLabelStyle: props.sectorLabelStyle,
|
||||||
|
highlightStyle: props.highlightStyle,
|
||||||
|
highlightOnly: props.highlightOnly,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 主绘制入口:根据 highlightOnly 决定画完整靶纸,还是只画透明高亮层。
|
||||||
|
const drawTarget = ({ force = false, highlightProgress = 1 } = {}) => {
|
||||||
|
const width = Math.max(getNumber(canvasSize.value.width, defaultCanvasSize), 1);
|
||||||
|
const height = Math.max(getNumber(canvasSize.value.height, defaultCanvasSize), 1);
|
||||||
|
const drawKey = getDrawKey(width, height);
|
||||||
|
|
||||||
|
if (!force && drawKey === lastDrawKey.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = Math.min(width, height);
|
||||||
|
const centerX = width / 2;
|
||||||
|
const centerY = height / 2;
|
||||||
|
const targetRadius = size / 2;
|
||||||
|
const config = mergeTargetStyleConfig();
|
||||||
|
const ctx = uni.createCanvasContext(currentCanvasId.value, instance?.proxy);
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
|
||||||
|
if (!props.highlightOnly) {
|
||||||
|
drawTargetRings(ctx, centerX, centerY, targetRadius, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSectorHighlight(
|
||||||
|
ctx,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
targetRadius,
|
||||||
|
config,
|
||||||
|
highlightProgress
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!props.highlightOnly) {
|
||||||
|
drawRingLines(ctx, centerX, centerY, targetRadius, config);
|
||||||
|
drawCircle(
|
||||||
|
ctx,
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
Math.max(1, targetRadius * config.centerDotRadiusRatio),
|
||||||
|
config.centerDotColor
|
||||||
|
);
|
||||||
|
drawCrosshair(ctx, centerX, centerY, targetRadius);
|
||||||
|
drawRingLabels(ctx, centerX, centerY, targetRadius, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 高亮先画,等分线和编号后画,避免高亮覆盖区域边界。
|
||||||
|
drawSectorLines(ctx, centerX, centerY, targetRadius);
|
||||||
|
drawSectorLabels(ctx, centerX, centerY, targetRadius);
|
||||||
|
|
||||||
|
ctx.draw();
|
||||||
|
lastDrawKey.value = highlightProgress >= 1 ? drawKey : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelHighlightAnimation = () => {
|
||||||
|
highlightAnimationGeneration += 1;
|
||||||
|
if (highlightAnimationTimer) {
|
||||||
|
clearTimeout(highlightAnimationTimer);
|
||||||
|
highlightAnimationTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 固定 6 帧展开黄色扇区,避免对原生 canvas 节点做缩放导致低端机错位。
|
||||||
|
const runHighlightRevealAnimation = () => {
|
||||||
|
cancelHighlightAnimation();
|
||||||
|
const generation = highlightAnimationGeneration;
|
||||||
|
let frameIndex = 0;
|
||||||
|
|
||||||
|
const drawNextFrame = () => {
|
||||||
|
if (generation !== highlightAnimationGeneration) return;
|
||||||
|
|
||||||
|
drawTarget({
|
||||||
|
force: true,
|
||||||
|
highlightProgress: highlightRevealProgressFrames[frameIndex],
|
||||||
|
});
|
||||||
|
frameIndex += 1;
|
||||||
|
|
||||||
|
if (frameIndex < highlightRevealProgressFrames.length) {
|
||||||
|
highlightAnimationTimer = setTimeout(
|
||||||
|
drawNextFrame,
|
||||||
|
highlightRevealFrameInterval
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
highlightAnimationTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
drawNextFrame();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCanvasSizeAndDraw = async (
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
{ animateHighlight = false, requestGeneration } = {}
|
||||||
|
) => {
|
||||||
|
canvasSize.value = {
|
||||||
|
width: width > 0 ? width : defaultCanvasSize,
|
||||||
|
height: height > 0 ? height : width || defaultCanvasSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
if (requestGeneration !== drawRequestGeneration) return;
|
||||||
|
|
||||||
|
const canAnimateHighlight =
|
||||||
|
animateHighlight &&
|
||||||
|
props.highlightOnly &&
|
||||||
|
!!getSectorAngles(props.activeSector, props.sectorCount);
|
||||||
|
|
||||||
|
if (canAnimateHighlight) {
|
||||||
|
runHighlightRevealAnimation();
|
||||||
|
} else {
|
||||||
|
cancelHighlightAnimation();
|
||||||
|
drawTarget();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 读取 canvas 实际渲染尺寸后再绘制,保证小程序真机尺寸和坐标一致。
|
||||||
|
const measureAndDraw = ({ animateHighlight = false } = {}) => {
|
||||||
|
const requestGeneration = ++drawRequestGeneration;
|
||||||
|
const propWidth = Math.round(getNumber(props.canvasWidth, 0));
|
||||||
|
const propHeight = Math.round(getNumber(props.canvasHeight, 0));
|
||||||
|
|
||||||
|
if (propWidth > 0 && propHeight > 0) {
|
||||||
|
setCanvasSizeAndDraw(propWidth, propHeight, {
|
||||||
|
animateHighlight,
|
||||||
|
requestGeneration,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = uni.createSelectorQuery().in(instance?.proxy);
|
||||||
|
|
||||||
|
query
|
||||||
|
.select(`#${currentCanvasId.value}`)
|
||||||
|
.boundingClientRect(async (rect) => {
|
||||||
|
if (requestGeneration !== drawRequestGeneration) return;
|
||||||
|
|
||||||
|
const width = Math.round(getNumber(rect?.width, defaultCanvasSize));
|
||||||
|
const height = Math.round(getNumber(rect?.height, width || defaultCanvasSize));
|
||||||
|
|
||||||
|
await setCanvasSizeAndDraw(width, height, {
|
||||||
|
animateHighlight,
|
||||||
|
requestGeneration,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.exec();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 等待 Vue 完成 DOM 更新后重新测量和绘制。
|
||||||
|
const scheduleDraw = async ({ animateHighlight = false } = {}) => {
|
||||||
|
await nextTick();
|
||||||
|
measureAndDraw({ animateHighlight });
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
props.coordinateRadius,
|
||||||
|
props.showCrosshair,
|
||||||
|
props.showRingLabels,
|
||||||
|
props.sectorCount,
|
||||||
|
props.activeSector,
|
||||||
|
props.activeRing,
|
||||||
|
props.highlightRefreshToken,
|
||||||
|
props.showSectorLabels,
|
||||||
|
props.highlightOnly,
|
||||||
|
props.canvasWidth,
|
||||||
|
props.canvasHeight,
|
||||||
|
props.targetStyleConfig,
|
||||||
|
props.crosshairStyle,
|
||||||
|
props.sectorStyle,
|
||||||
|
props.sectorLabelStyle,
|
||||||
|
props.highlightStyle,
|
||||||
|
],
|
||||||
|
(currentValues, previousValues = []) => {
|
||||||
|
const refreshTokenIndex = 6;
|
||||||
|
const refreshToken = Number(currentValues[refreshTokenIndex]);
|
||||||
|
const previousRefreshToken = Number(previousValues[refreshTokenIndex]);
|
||||||
|
const animateHighlight =
|
||||||
|
Number.isFinite(refreshToken) &&
|
||||||
|
refreshToken > 0 &&
|
||||||
|
refreshToken !== previousRefreshToken;
|
||||||
|
|
||||||
|
return scheduleDraw({ animateHighlight });
|
||||||
|
},
|
||||||
|
{
|
||||||
|
deep: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mountDrawTimer = setTimeout(measureAndDraw, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
drawRequestGeneration += 1;
|
||||||
|
cancelHighlightAnimation();
|
||||||
|
if (mountDrawTimer) {
|
||||||
|
clearTimeout(mountDrawTimer);
|
||||||
|
mountDrawTimer = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas
|
||||||
|
:id="currentCanvasId"
|
||||||
|
class="target-canvas"
|
||||||
|
:canvas-id="currentCanvasId"
|
||||||
|
:width="canvasSize.width"
|
||||||
|
:height="canvasSize.height"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.target-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import SButton from "@/components/SButton.vue";
|
import SButton from "@/components/SButton.vue";
|
||||||
|
import audioManager from "@/audioManager";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
show: {
|
show: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
clickSound: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
onClose: {
|
onClose: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
@@ -39,7 +44,19 @@ watch(
|
|||||||
{}
|
{}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const playClickSound = () => {
|
||||||
|
if (props.clickSound) {
|
||||||
|
audioManager.play("点击按钮");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectTarget = (target) => {
|
||||||
|
playClickSound();
|
||||||
|
selectedTarget.value = target;
|
||||||
|
};
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
|
playClickSound();
|
||||||
props.onConfirm(selectedTarget.value);
|
props.onConfirm(selectedTarget.value);
|
||||||
props.onClose();
|
props.onClose();
|
||||||
};
|
};
|
||||||
@@ -70,14 +87,14 @@ const handleConfirm = () => {
|
|||||||
<view class="target-options">
|
<view class="target-options">
|
||||||
<view
|
<view
|
||||||
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 1 }"
|
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 1 }"
|
||||||
@click="() => (selectedTarget = 1)"
|
@click="handleSelectTarget(1)"
|
||||||
>
|
>
|
||||||
<text>20厘米全环靶</text>
|
<text>20厘米全环靶</text>
|
||||||
</view>
|
</view>
|
||||||
<view style="width: 30rpx"></view>
|
<view style="width: 30rpx"></view>
|
||||||
<view
|
<view
|
||||||
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 2 }"
|
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 2 }"
|
||||||
@click="() => (selectedTarget = 2)"
|
@click="handleSelectTarget(2)"
|
||||||
>
|
>
|
||||||
<text>40厘米全环靶</text>
|
<text>40厘米全环靶</text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import Avatar from "@/components/Avatar.vue";
|
|||||||
import audioManager from "@/audioManager";
|
import audioManager from "@/audioManager";
|
||||||
import { simulShootAPI } from "@/apis";
|
import { simulShootAPI } from "@/apis";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
|
import {
|
||||||
|
getDistanceCheckAudioKey,
|
||||||
|
getDistanceCheckText,
|
||||||
|
getShootValidation,
|
||||||
|
} from "@/util";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user, device } = storeToRefs(store);
|
const { user, device } = storeToRefs(store);
|
||||||
|
const emit = defineEmits(["passed"]);
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
guide: {
|
guide: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -23,18 +29,50 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 15,
|
default: 15,
|
||||||
},
|
},
|
||||||
|
targetType: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 40,
|
||||||
|
},
|
||||||
|
autoStart: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const arrow = ref({});
|
const arrow = ref({});
|
||||||
const distance = ref(0);
|
const distance = ref(0);
|
||||||
|
const statusText = ref("");
|
||||||
const showsimul = ref(false);
|
const showsimul = ref(false);
|
||||||
const count = ref(props.count);
|
const count = ref(props.count);
|
||||||
const timer = ref(null);
|
const timer = ref(null);
|
||||||
|
const autoStartPending = ref(false);
|
||||||
|
const autoStartTriggered = ref(false);
|
||||||
|
let autoStartTimer = null;
|
||||||
|
const DISTANCE_PASSED_AUDIO_KEY = "站距合格,靶纸正确";
|
||||||
|
const AUTO_START_TIMEOUT_MS = 6000;
|
||||||
|
|
||||||
|
const clearAutoStartTimer = () => {
|
||||||
|
if (!autoStartTimer) return;
|
||||||
|
clearTimeout(autoStartTimer);
|
||||||
|
autoStartTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerAutoStart = () => {
|
||||||
|
if (!autoStartPending.value || autoStartTriggered.value) return;
|
||||||
|
clearAutoStartTimer();
|
||||||
|
autoStartPending.value = false;
|
||||||
|
autoStartTriggered.value = true;
|
||||||
|
emit("passed");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAudioEnded = (key) => {
|
||||||
|
if (key === DISTANCE_PASSED_AUDIO_KEY) triggerAutoStart();
|
||||||
|
};
|
||||||
|
|
||||||
const updateTimer = (value) => {
|
const updateTimer = (value) => {
|
||||||
count.value = Math.round(value);
|
count.value = Math.round(value);
|
||||||
};
|
};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
audioManager.play("请射箭测试距离");
|
audioManager.play("请射箭!测试站距与靶纸");
|
||||||
if (props.isBattle) {
|
if (props.isBattle) {
|
||||||
timer.value = setInterval(() => {
|
timer.value = setInterval(() => {
|
||||||
count.value -= 1;
|
count.value -= 1;
|
||||||
@@ -42,23 +80,43 @@ onMounted(() => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
uni.$on("update-timer", updateTimer);
|
uni.$on("update-timer", updateTimer);
|
||||||
|
uni.$on("audioEnded", onAudioEnded);
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (timer.value) clearInterval(timer.value);
|
if (timer.value) clearInterval(timer.value);
|
||||||
|
clearAutoStartTimer();
|
||||||
uni.$off("update-timer", updateTimer);
|
uni.$off("update-timer", updateTimer);
|
||||||
|
uni.$off("audioEnded", onAudioEnded);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onReceiveMessage(msg) {
|
async function onReceiveMessage(msg) {
|
||||||
if (Array.isArray(msg)) return;
|
if (Array.isArray(msg)) return;
|
||||||
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
||||||
distance.value = Number((msg.shootData.distance / 100).toFixed(2));
|
if (autoStartPending.value || autoStartTriggered.value) return;
|
||||||
if (distance.value >= 5) audioManager.play("距离合格");
|
const rawDistance = Number(msg.shootData?.distance ?? msg.shootData?.dst);
|
||||||
else audioManager.play("距离不足");
|
distance.value = Number.isFinite(rawDistance)
|
||||||
|
? Number((rawDistance / 100).toFixed(2))
|
||||||
|
: 0;
|
||||||
|
statusText.value = getDistanceCheckText(msg.shootData);
|
||||||
|
const audioKey = getDistanceCheckAudioKey(msg.shootData);
|
||||||
|
const validation = getShootValidation(msg.shootData);
|
||||||
|
if (props.autoStart && validation.distanceOk && validation.targetOk) {
|
||||||
|
autoStartPending.value = true;
|
||||||
|
autoStartTimer = setTimeout(triggerAutoStart, AUTO_START_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
audioManager.play(audioKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const simulShoot = async () => {
|
const simulShoot = async () => {
|
||||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
if (device.value.deviceId) {
|
||||||
|
await simulShootAPI(
|
||||||
|
device.value.deviceId,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
props.targetType
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
@@ -96,13 +154,11 @@ onBeforeUnmount(() => {
|
|||||||
模拟射箭
|
模拟射箭
|
||||||
</button>
|
</button>
|
||||||
<view class="warnning-text">
|
<view class="warnning-text">
|
||||||
<block v-if="distance > 0">
|
<block v-if="statusText">
|
||||||
<text>当前距离{{ distance }}米</text>
|
<text>{{ statusText }}</text>
|
||||||
<text v-if="distance >= 5">已达到距离要求</text>
|
|
||||||
<text v-else>请调整站位</text>
|
|
||||||
</block>
|
</block>
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<text>请射箭,测试站距</text>
|
<text>请射箭,测试站距与靶纸</text>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
<view class="user-row">
|
<view class="user-row">
|
||||||
@@ -111,9 +167,9 @@ onBeforeUnmount(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="isBattle" class="ready-timer">
|
<view v-if="isBattle" class="ready-timer">
|
||||||
<image src="../static/test-tip.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/test-tip.png" mode="widthFix" />
|
||||||
<view v-if="count >= 0">
|
<view v-if="count >= 0">
|
||||||
<text>具体正式比赛还有</text>
|
<text>距离正式比赛还有</text>
|
||||||
<text>{{ count }}</text>
|
<text>{{ count }}</text>
|
||||||
<text>秒</text>
|
<text>秒</text>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ const props = defineProps({
|
|||||||
const show = ref(false);
|
const show = ref(false);
|
||||||
const count = ref(props.countdown);
|
const count = ref(props.countdown);
|
||||||
const timer = ref(null);
|
const timer = ref(null);
|
||||||
|
const showTimer = ref(null);
|
||||||
const updateTimer = (value) => {
|
const updateTimer = (value) => {
|
||||||
count.value = Math.round(value);
|
count.value = Math.round(value);
|
||||||
};
|
};
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setTimeout(() => {
|
showTimer.value = setTimeout(() => {
|
||||||
|
showTimer.value = null;
|
||||||
show.value = true;
|
show.value = true;
|
||||||
timer.value = setInterval(() => {
|
timer.value = setInterval(() => {
|
||||||
if (count.value === 0) {
|
if (count.value === 0) {
|
||||||
@@ -27,7 +29,10 @@ onMounted(() => {
|
|||||||
uni.$on("update-timer", updateTimer);
|
uni.$on("update-timer", updateTimer);
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
if (showTimer.value) clearTimeout(showTimer.value);
|
||||||
|
showTimer.value = null;
|
||||||
if (timer.value) clearInterval(timer.value);
|
if (timer.value) clearInterval(timer.value);
|
||||||
|
timer.value = null;
|
||||||
uni.$off("update-timer", updateTimer);
|
uni.$off("update-timer", updateTimer);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const MAX_VISIBLE_SCORE_CARDS = 60;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
arrows: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
trainingType: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
recordMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDisplayText = (arrow = {}) => {
|
||||||
|
if (!arrow) return "";
|
||||||
|
if (!arrow.ring) return props.trainingType === "stability" ? "-" : "0";
|
||||||
|
return arrow.ringX ? "X" : String(arrow.ring);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFailed = (arrow = {}) => {
|
||||||
|
if (!arrow) return false;
|
||||||
|
if (
|
||||||
|
props.recordMode &&
|
||||||
|
props.trainingType !== "precision" &&
|
||||||
|
props.trainingType !== "rhythm" &&
|
||||||
|
props.trainingType !== "stability"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return arrow.ok !== true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hiddenScoreCount = computed(() =>
|
||||||
|
props.recordMode
|
||||||
|
? 0
|
||||||
|
: Math.max(props.arrows.length - MAX_VISIBLE_SCORE_CARDS, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayArrows = computed(() => {
|
||||||
|
const list = props.recordMode
|
||||||
|
? [...props.arrows]
|
||||||
|
: props.arrows.slice(-MAX_VISIBLE_SCORE_CARDS);
|
||||||
|
// total 是达标箭数,不是实际射箭上限;训练中始终预留下一箭空框。
|
||||||
|
if (!props.recordMode) list.push(null);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view v-if="displayArrows.length" class="score-panel">
|
||||||
|
<text v-if="hiddenScoreCount" class="score-window-tip">
|
||||||
|
已省略前 {{ hiddenScoreCount }} 支,仅显示最近
|
||||||
|
{{ MAX_VISIBLE_SCORE_CARDS }} 支
|
||||||
|
</text>
|
||||||
|
<view class="score-grid">
|
||||||
|
<view
|
||||||
|
v-for="(arrow, index) in displayArrows"
|
||||||
|
:key="index"
|
||||||
|
class="score-card"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="score-card-bg"
|
||||||
|
:src="
|
||||||
|
isFailed(arrow)
|
||||||
|
? 'https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/block-gray.png'
|
||||||
|
: 'https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/block-gold.png'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
v-if="trainingType === 'precision' && arrow"
|
||||||
|
class="score-result-icon"
|
||||||
|
:src="
|
||||||
|
arrow.ok === true
|
||||||
|
? 'https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/gou.png'
|
||||||
|
: 'https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/cha.png'
|
||||||
|
"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
v-else
|
||||||
|
class="score-value"
|
||||||
|
:class="{ 'score-value--low': isFailed(arrow) }"
|
||||||
|
>
|
||||||
|
{{ getDisplayText(arrow) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.score-panel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 30rpx 40rpx 0 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-window-tip {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card {
|
||||||
|
position: relative;
|
||||||
|
width: 100rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-right: 14rpx;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card:nth-child(6n) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-card-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-result-icon {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 30rpx;
|
||||||
|
height: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-value {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
min-width: 28rpx;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 34rpx;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
color: #f6e3b2;
|
||||||
|
text-shadow: 0 2rpx 0 rgba(36, 36, 48, 0.5);
|
||||||
|
margin-left: -10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-value--low {
|
||||||
|
color: #cfcfcf;
|
||||||
|
text-shadow: 0 2rpx 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,10 @@ const props = defineProps({
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
|
fullNickname: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
onSignin: {
|
onSignin: {
|
||||||
type: Function,
|
type: Function,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
@@ -19,6 +23,8 @@ const nextLvlPoints = ref(0);
|
|||||||
const containerWidth = computed(() =>
|
const containerWidth = computed(() =>
|
||||||
props.showRank ? "72%" : "calc(100% - 15px)"
|
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 toUserPage = () => {
|
||||||
// 获取当前页面路径
|
// 获取当前页面路径
|
||||||
const pages = getCurrentPages();
|
const pages = getCurrentPages();
|
||||||
@@ -59,7 +65,13 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="container" :style="{ width: containerWidth }">
|
<view
|
||||||
|
:class="[
|
||||||
|
'container',
|
||||||
|
fullNickname ? 'container--full-nickname' : '',
|
||||||
|
]"
|
||||||
|
:style="{ width: containerWidth }"
|
||||||
|
>
|
||||||
<block v-if="user.id">
|
<block v-if="user.id">
|
||||||
<Avatar
|
<Avatar
|
||||||
:rankLvl="user.rankLvl"
|
:rankLvl="user.rankLvl"
|
||||||
@@ -69,12 +81,23 @@ watch(
|
|||||||
/>
|
/>
|
||||||
<view class="user-details" @click="toUserPage">
|
<view class="user-details" @click="toUserPage">
|
||||||
<view class="user-name">
|
<view class="user-name">
|
||||||
<text>{{ user.nickName }}</text>
|
<view
|
||||||
<image
|
: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"
|
class="user-name-image"
|
||||||
src="../static/vip1.png"
|
src="../static/vip1.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/> -->
|
||||||
</view>
|
</view>
|
||||||
<view class="user-stats">
|
<view class="user-stats">
|
||||||
<text class="level-tag level-tag-first">段位积分</text>
|
<text class="level-tag level-tag-first">段位积分</text>
|
||||||
@@ -148,12 +171,26 @@ watch(
|
|||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name > text:first-child {
|
.user-name .member-nickname {
|
||||||
font-size: 13px;
|
|
||||||
max-width: 180rpx;
|
max-width: 180rpx;
|
||||||
white-space: nowrap;
|
}
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
.container--full-nickname .user-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container--full-nickname .user-name {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container--full-nickname .user-name .member-nickname {
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name .member-nickname__text,
|
||||||
|
.user-name .member-nickname__shine {
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-name-image {
|
.user-name-image {
|
||||||
|
|||||||
@@ -54,23 +54,23 @@ onBeforeUnmount(() => {
|
|||||||
<view v-if="showRank" class="up-rank">
|
<view v-if="showRank" class="up-rank">
|
||||||
<image :src="user.avatar || '../static/user-icon.png'" mode="widthFix" />
|
<image :src="user.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||||
<image :src="nextRankImage" mode="widthFix" />
|
<image :src="nextRankImage" mode="widthFix" />
|
||||||
<image class="bg-effect" src="../static/shining-bg.png" mode="widthFix" />
|
<image class="bg-effect" src="https://static.shelingxingqiu.com/shootmini/static/shining-bg.png" mode="widthFix" />
|
||||||
<image
|
<image
|
||||||
class="bg-effect"
|
class="bg-effect"
|
||||||
src="../static/gold-shining.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/gold-shining.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="showGrade" class="up-grade">
|
<view v-if="showGrade" class="up-grade">
|
||||||
<image
|
<image
|
||||||
class="scale-in"
|
class="scale-in"
|
||||||
src="../static/user-upgrade.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/user-upgrade.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<image class="bg-effect" src="../static/shining-bg.png" mode="widthFix" />
|
<image class="bg-effect" src="https://static.shelingxingqiu.com/shootmini/static/shining-bg.png" mode="widthFix" />
|
||||||
<image
|
<image
|
||||||
class="bg-effect"
|
class="bg-effect"
|
||||||
src="../static/gold-shining.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/gold-shining.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export const MESSAGETYPES = {
|
export const MESSAGETYPES = {
|
||||||
ShootSyncMeArrowID: parseInt("0x789b6b0d"), // 2023451405
|
ShootSyncMeArrowID: parseInt("0x789b6b0d"), // 2023451405
|
||||||
ShootSyncMePracticeID: parseInt("0xD88AE05E"), // 3632980062
|
ShootSyncMePracticeID: parseInt("0xD88AE05E"), // 3632980062
|
||||||
|
ShootSyncBattleCreateMatchLinkID: parseInt("0xF86FF466"), // 2538869862
|
||||||
WaitForAllReady: parseInt("0x615C13BE"), // 1633424318
|
WaitForAllReady: parseInt("0x615C13BE"), // 1633424318
|
||||||
AllReady: parseInt("0x1CCB49FD"), // 483084797
|
AllReady: parseInt("0x1CCB49FD"), // 483084797
|
||||||
MeleeAllReady: parseInt("0x37132BD5"), // 924003285
|
MeleeAllReady: parseInt("0x37132BD5"), // 924003285
|
||||||
@@ -28,6 +29,7 @@ export const MESSAGETYPES = {
|
|||||||
DeviceOnline: 4168086626,
|
DeviceOnline: 4168086626,
|
||||||
DeviceOffline: 4168086627,
|
DeviceOffline: 4168086627,
|
||||||
SomeoneIsReady: 4168086628,
|
SomeoneIsReady: 4168086628,
|
||||||
|
DeviceCharging: 4168086631, // 设备充电中
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MESSAGETYPESV2 = {
|
export const MESSAGETYPESV2 = {
|
||||||
|
|||||||
+252
-1
@@ -53,6 +53,253 @@
|
|||||||
"quickapp" : {},
|
"quickapp" : {},
|
||||||
"mp-weixin" : {
|
"mp-weixin" : {
|
||||||
"appid" : "wxa8f5989dcd45cc23",
|
"appid" : "wxa8f5989dcd45cc23",
|
||||||
|
"packOptions" : {
|
||||||
|
"ignore" : [
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/common/dialog-light.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/common/dialog-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/common/dialog-icon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/prompt-bg-square.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/coach-comment.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/screen-hint-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/red-team-win.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/blue-team-win.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/my-grow.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/gold-shining.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/bow-target.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/matching-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/versus.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/point-champion.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/my-practise.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/shining-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/donate.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/friend-battle.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/user-upgrade.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/finish-frame.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/vip/svip-jian.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/battle-header.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/battle-header-melee.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/player-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/mvp-blue.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/finish-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/2unfinish-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/have-no-device.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/device-icon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/unfinish-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/test-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/mvp-red.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/vip/svip-lie.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/mvp-tip.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/rank/battle-choose.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/back-to-game-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/complete-light1.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/complete-light2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/title-2v2.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/tab-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/scan.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/choose-battle-mode.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/shooter.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/my-growth.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/juezhanbang.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/title-3v3.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/point-book-tip-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/reward-us.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/rank/star.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/tab-point-book.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/pk-icon.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/first-try.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/row-yellow-bg.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/room-notfound-title.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/title-mvp.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/long-bubble-tall.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/battle-result.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "file",
|
||||||
|
"value" : "static/tab-mall.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "folder",
|
||||||
|
"value" : "static/training-home"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "folder",
|
||||||
|
"value" : "static/training-difficulty-design"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"optimization" : {
|
||||||
|
"subPackages" : true
|
||||||
|
},
|
||||||
"setting" : {
|
"setting" : {
|
||||||
"urlCheck" : false,
|
"urlCheck" : false,
|
||||||
"minified" : true,
|
"minified" : true,
|
||||||
@@ -64,7 +311,11 @@
|
|||||||
"usingComponents" : true,
|
"usingComponents" : true,
|
||||||
"darkmode" : true,
|
"darkmode" : true,
|
||||||
"themeLocation" : "theme.json",
|
"themeLocation" : "theme.json",
|
||||||
"permission" : {},
|
"permission" : {
|
||||||
|
"scope.userLocation": {
|
||||||
|
"desc": "用于扫描附近 WiFi,完成设备 OTA 升级网络连接"
|
||||||
|
}
|
||||||
|
},
|
||||||
"requiredPrivateInfos" : [ "getLocation", "chooseLocation" ]
|
"requiredPrivateInfos" : [ "getLocation", "chooseLocation" ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+87
-16
@@ -3,6 +3,9 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/index"
|
"path": "pages/index"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/org-bind"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/friend-battle"
|
"path": "pages/friend-battle"
|
||||||
},
|
},
|
||||||
@@ -30,9 +33,6 @@
|
|||||||
"navigationBarTitleText": ""
|
"navigationBarTitleText": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/team-battle/index"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/melee-battle"
|
"path": "pages/melee-battle"
|
||||||
},
|
},
|
||||||
@@ -69,15 +69,6 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/user"
|
"path": "pages/user"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/orders"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "pages/order-detail"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "pages/be-vip"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/grade-intro"
|
"path": "pages/grade-intro"
|
||||||
},
|
},
|
||||||
@@ -111,14 +102,17 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/match-detail"
|
"path": "pages/match-detail"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "pages/team-bow-data"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "pages/melee-bow-data"
|
"path": "pages/melee-bow-data"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/mine-bow-data"
|
"path": "pages/mine-bow-data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ota-wifi",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
@@ -138,5 +132,82 @@
|
|||||||
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"subPackages": []
|
"subPackages": [
|
||||||
|
{
|
||||||
|
"root": "pages/coin",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "rules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "earning-records"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "exchange-records"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "nearby-stores"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "product-detail"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "pages/device",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "unbind-device"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "pages/member",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "orders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "order-detail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "be-vip"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "vip-intro"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "agreement"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "pages/team-battle",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "team-bow-data"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"root": "pages/training",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "index"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "difficulty"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "practise-one"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,23 @@ const playAudio = (key) => {
|
|||||||
audioManager.play(key);
|
audioManager.play(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onAudioLoaded = (key) => {
|
||||||
|
loaded.value = {
|
||||||
|
...loaded.value,
|
||||||
|
[key]: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const loadedAudioKeys = uni.getStorageSync("loadedAudioKeys") || {};
|
const loadedAudioKeys = uni.getStorageSync("loadedAudioKeys") || {};
|
||||||
loaded.value = loadedAudioKeys;
|
loaded.value = loadedAudioKeys;
|
||||||
|
|
||||||
uni.$on("audioLoaded", (key) => {
|
uni.$on("audioLoaded", onAudioLoaded);
|
||||||
loaded.value[key] = true;
|
void audioManager.initAudios();
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
uni.$off("audioLoaded");
|
uni.$off("audioLoaded", onAudioLoaded);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -40,8 +46,10 @@ onBeforeUnmount(() => {
|
|||||||
</view>
|
</view>
|
||||||
<view v-for="key in Object.keys(audioFils)" :key="key">
|
<view v-for="key in Object.keys(audioFils)" :key="key">
|
||||||
<text>{{ key }}</text>
|
<text>{{ key }}</text>
|
||||||
<text v-if="!loaded[key]">未加载</text>
|
<text>{{ loaded[key] ? "已加载" : "未加载" }}</text>
|
||||||
<button v-else hover-class="none" @click="playAudio(key)">播放</button>
|
<button hover-class="none" @click="playAudio(key)">
|
||||||
|
{{ loaded[key] ? "播放" : "加载并播放" }}
|
||||||
|
</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
|||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import Avatar from "@/components/Avatar.vue";
|
import Avatar from "@/components/Avatar.vue";
|
||||||
import UserUpgrade from "@/components/UserUpgrade.vue";
|
import UserUpgrade from "@/components/UserUpgrade.vue";
|
||||||
|
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||||
import { getBattleAPI } from "@/apis";
|
import { getBattleAPI } from "@/apis";
|
||||||
import { topThreeColors, getBattleResultTips } from "@/constants";
|
import { topThreeColors, getBattleResultTips } from "@/constants";
|
||||||
import audioManager from "@/audioManager";
|
import audioManager from "@/audioManager";
|
||||||
@@ -82,7 +83,7 @@ const checkBowData = () => {
|
|||||||
<view class="container">
|
<view class="container">
|
||||||
<block v-if="data.mode <= 3">
|
<block v-if="data.mode <= 3">
|
||||||
<view class="header-team" :style="{ marginTop: '25%' }">
|
<view class="header-team" :style="{ marginTop: '25%' }">
|
||||||
<image src="../static/battle-result.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/battle-result.png" mode="widthFix" />
|
||||||
<view class="header-solo" v-if="data.mode === 1">
|
<view class="header-solo" v-if="data.mode === 1">
|
||||||
<text
|
<text
|
||||||
:style="{
|
:style="{
|
||||||
@@ -107,7 +108,7 @@ const checkBowData = () => {
|
|||||||
</view>
|
</view>
|
||||||
<view class="header-mvp" v-if="data.mode === 2 || data.mode === 3">
|
<view class="header-mvp" v-if="data.mode === 2 || data.mode === 3">
|
||||||
<image
|
<image
|
||||||
:src="`../static/${data.winTeam === 1 ? 'blue' : 'red'}-team-win.png`"
|
:src="`https://static.shelingxingqiu.com/shootmini/static/${data.winTeam === 1 ? 'blue' : 'red'}-team-win.png`"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
/>
|
/>
|
||||||
<view
|
<view
|
||||||
@@ -117,8 +118,8 @@ const checkBowData = () => {
|
|||||||
}deg)`,
|
}deg)`,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<view v-if="data.mvp && data.mvp.totalRings">
|
<view v-if="data.mvp && data.mvp.player_match_result.total_ring">
|
||||||
<image src="../static/title-mvp.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/title-mvp.png" mode="widthFix" />
|
||||||
<text
|
<text
|
||||||
>斩获<text
|
>斩获<text
|
||||||
:style="{
|
:style="{
|
||||||
@@ -127,7 +128,7 @@ const checkBowData = () => {
|
|||||||
margin: '0 3px',
|
margin: '0 3px',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
}"
|
}"
|
||||||
>{{ data.mvp.totalRings }}</text
|
>{{ data.mvp.player_match_result.total_ring }}</text
|
||||||
>环</text
|
>环</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
@@ -153,7 +154,7 @@ const checkBowData = () => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="battle-winner">
|
<view class="battle-winner">
|
||||||
<image src="../static/shining-bg.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/shining-bg.png" mode="widthFix" />
|
||||||
<image
|
<image
|
||||||
:src="ifWin ? '../static/you-win.png' : '../static/you-lost.png'"
|
:src="ifWin ? '../static/you-win.png' : '../static/you-lost.png'"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
@@ -173,7 +174,7 @@ const checkBowData = () => {
|
|||||||
<block v-else>
|
<block v-else>
|
||||||
<view class="header-melee">
|
<view class="header-melee">
|
||||||
<view />
|
<view />
|
||||||
<image src="../static/battle-result.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/battle-result.png" mode="widthFix" />
|
||||||
<view />
|
<view />
|
||||||
</view>
|
</view>
|
||||||
<view
|
<view
|
||||||
@@ -248,7 +249,7 @@ const checkBowData = () => {
|
|||||||
class="battle-e"
|
class="battle-e"
|
||||||
:style="{ marginTop: data.mode > 3 ? '20px' : '20vw' }"
|
:style="{ marginTop: data.mode > 3 ? '20px' : '20vw' }"
|
||||||
>
|
>
|
||||||
<image src="../static/row-yellow-bg.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/row-yellow-bg.png" mode="widthFix" />
|
||||||
<view class="team-avatar">
|
<view class="team-avatar">
|
||||||
<Avatar
|
<Avatar
|
||||||
:src="user.avatar"
|
:src="user.avatar"
|
||||||
@@ -287,6 +288,7 @@ const checkBowData = () => {
|
|||||||
<view @click="exit">返回</view>
|
<view @click="exit">返回</view>
|
||||||
</view>
|
</view>
|
||||||
<UserUpgrade />
|
<UserUpgrade />
|
||||||
|
<DeviceChargingDialog />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
+311
-45
@@ -1,23 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, onBeforeUnmount } from "vue";
|
import { ref, onMounted, computed, onBeforeUnmount } from "vue";
|
||||||
import { onShow, onLoad, onShareAppMessage } from "@dcloudio/uni-app";
|
import { onShow, onHide, onLoad, onShareAppMessage } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import PlayerSeats from "@/components/PlayerSeats.vue";
|
import PlayerSeats from "@/components/PlayerSeats.vue";
|
||||||
import GuideTwo from "@/components/GuideTwo.vue";
|
import GuideTwo from "@/components/GuideTwo.vue";
|
||||||
import SButton from "@/components/SButton.vue";
|
import SButton from "@/components/SButton.vue";
|
||||||
import Avatar from "@/components/Avatar.vue";
|
import Avatar from "@/components/Avatar.vue";
|
||||||
import ScreenHint from "@/components/ScreenHint.vue";
|
import ScreenHint from "@/components/ScreenHint.vue";
|
||||||
|
import ModalDialog from "@/components/ModalDialog.vue";
|
||||||
import {
|
import {
|
||||||
getRoomAPI,
|
getRoomAPI,
|
||||||
exitRoomAPI,
|
exitRoomAPI,
|
||||||
chooseTeamAPI,
|
chooseTeamAPI,
|
||||||
getReadyAPI,
|
getReadyAPI,
|
||||||
kickPlayerAPI,
|
kickPlayerAPI,
|
||||||
|
getBattleAPI,
|
||||||
} from "@/apis";
|
} from "@/apis";
|
||||||
|
import { debounce, isLimitError } from "@/util";
|
||||||
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import audioManager from "@/audioManager";
|
import audioManager from "@/audioManager";
|
||||||
|
import { returnToBattle } from "@/utils/matchReturn";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user } = storeToRefs(store);
|
const { user } = storeToRefs(store);
|
||||||
|
|
||||||
@@ -29,6 +33,20 @@ const players = ref([]);
|
|||||||
const blueTeam = ref([]);
|
const blueTeam = ref([]);
|
||||||
const redTeam = ref([]);
|
const redTeam = ref([]);
|
||||||
|
|
||||||
|
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" : "",
|
||||||
|
];
|
||||||
|
|
||||||
|
const normalizeRoomPlayer = (member = {}) => ({
|
||||||
|
...(member.userInfo || {}),
|
||||||
|
vip: member.vip ?? member.userInfo?.vip,
|
||||||
|
sVip: member.sVip ?? member.userInfo?.sVip,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据对战类型和房间人数动态生成页面标题
|
* 根据对战类型和房间人数动态生成页面标题
|
||||||
* battleType=1: 组队对战;count 2/4/6 分别对应 1v1/2v2/3v3
|
* battleType=1: 组队对战;count 2/4/6 分别对应 1v1/2v2/3v3
|
||||||
@@ -56,17 +74,153 @@ const ready = ref(false);
|
|||||||
const allReady = ref(false);
|
const allReady = ref(false);
|
||||||
const timer = ref(null);
|
const timer = ref(null);
|
||||||
const goBattle = ref(false);
|
const goBattle = ref(false);
|
||||||
|
const showLimitModal = ref(false);
|
||||||
|
const readySubmitting = ref(false);
|
||||||
|
const navigationPending = ref(false);
|
||||||
|
let pageMounted = false;
|
||||||
|
let pageVisible = false;
|
||||||
|
let foregroundGeneration = 0;
|
||||||
|
const MATCH_INFO_RETRY_DELAY_MS = 500;
|
||||||
/** 从结算页返回时为 true,跳过进场靶纸语音 */
|
/** 从结算页返回时为 true,跳过进场靶纸语音 */
|
||||||
const skipTargetAudio = ref(false);
|
const skipTargetAudio = ref(false);
|
||||||
|
|
||||||
|
const wait = (delay) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCurrentForeground = (generation) =>
|
||||||
|
pageMounted &&
|
||||||
|
pageVisible &&
|
||||||
|
generation === foregroundGeneration &&
|
||||||
|
!navigationPending.value;
|
||||||
|
|
||||||
|
const redirectOnce = (url) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (navigationPending.value) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigationPending.value = true;
|
||||||
|
uni.redirectTo({
|
||||||
|
url,
|
||||||
|
success: resolve,
|
||||||
|
fail: (error) => {
|
||||||
|
navigationPending.value = false;
|
||||||
|
goBattle.value = false;
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function queryCurrentBattle(generation) {
|
||||||
|
try {
|
||||||
|
return await getBattleAPI();
|
||||||
|
} catch (error) {
|
||||||
|
await wait(MATCH_INFO_RETRY_DELAY_MS);
|
||||||
|
if (!isCurrentForeground(generation)) throw error;
|
||||||
|
return getBattleAPI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function returnToCurrentBattle(battle) {
|
||||||
|
if (!battle?.matchId || navigationPending.value) return false;
|
||||||
|
|
||||||
|
goBattle.value = true;
|
||||||
|
try {
|
||||||
|
const returned = await returnToBattle(battle, redirectOnce);
|
||||||
|
if (!returned) goBattle.value = false;
|
||||||
|
return returned;
|
||||||
|
} catch (error) {
|
||||||
|
goBattle.value = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcileBattleRoom(generation) {
|
||||||
|
let battle = null;
|
||||||
|
try {
|
||||||
|
battle = await queryCurrentBattle(generation);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
console.log("resume room match info error", error);
|
||||||
|
uni.showToast({
|
||||||
|
title: "比赛状态查询失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
if (battle?.matchId) {
|
||||||
|
try {
|
||||||
|
await returnToCurrentBattle(battle);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("return to room battle error", error);
|
||||||
|
uni.showToast({
|
||||||
|
title: "进入比赛失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let roomStarted = false;
|
||||||
|
try {
|
||||||
|
roomStarted = await refreshRoomData();
|
||||||
|
} catch (error) {
|
||||||
|
if (isCurrentForeground(generation)) {
|
||||||
|
console.log("refresh room data error", error);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentForeground(generation) || !roomStarted) return;
|
||||||
|
|
||||||
|
await wait(MATCH_INFO_RETRY_DELAY_MS);
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
battle = await queryCurrentBattle(generation);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
console.log("retry room match info error", error);
|
||||||
|
uni.showToast({
|
||||||
|
title: "比赛状态查询失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
if (battle?.matchId) {
|
||||||
|
try {
|
||||||
|
await returnToCurrentBattle(battle);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("return to room battle error", error);
|
||||||
|
uni.showToast({
|
||||||
|
title: "进入比赛失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showToast({
|
||||||
|
title: "比赛正在创建,请稍候",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从服务端刷新当前房间数据,更新成员列表、准备状态等信息
|
* 从服务端刷新当前房间数据,更新成员列表、准备状态等信息
|
||||||
* 仅在 roomNumber 有效且房间未开始时执行
|
* 仅在 roomNumber 有效且房间未开始时执行
|
||||||
*/
|
*/
|
||||||
async function refreshRoomData() {
|
async function refreshRoomData() {
|
||||||
if (!roomNumber.value) return;
|
if (!roomNumber.value) return false;
|
||||||
const result = await getRoomAPI(roomNumber.value);
|
const result = await getRoomAPI(roomNumber.value);
|
||||||
if (result.started) return;
|
if (result.started) return true;
|
||||||
room.value = result;
|
room.value = result;
|
||||||
// 加入者通过 API 返回的 targetType 字段同步靶纸尺寸,并持久化到本地缓存
|
// 加入者通过 API 返回的 targetType 字段同步靶纸尺寸,并持久化到本地缓存
|
||||||
if (result.targetType) {
|
if (result.targetType) {
|
||||||
@@ -91,13 +245,16 @@ async function refreshRoomData() {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
members.some((m) => {
|
members.some((m) => {
|
||||||
if (m.userInfo.id === result.creator) {
|
const player = normalizeRoomPlayer(m);
|
||||||
|
if (player.id === result.creator) {
|
||||||
owner.value = {
|
owner.value = {
|
||||||
id: m.userInfo.id,
|
id: player.id,
|
||||||
name: m.userInfo.name,
|
name: player.name,
|
||||||
avatar: m.userInfo.avatar,
|
avatar: player.avatar,
|
||||||
rankLvl: m.userInfo.rankLvl,
|
rankLvl: player.rankLvl,
|
||||||
ready: !!m.userInfo.state,
|
vip: player.vip,
|
||||||
|
sVip: player.sVip,
|
||||||
|
ready: !!player.state,
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -105,13 +262,16 @@ async function refreshRoomData() {
|
|||||||
});
|
});
|
||||||
if (result.battleType === 1 && result.count === 2) {
|
if (result.battleType === 1 && result.count === 2) {
|
||||||
result.members.forEach((m) => {
|
result.members.forEach((m) => {
|
||||||
if (m.userInfo.id !== owner.value.id) {
|
const player = normalizeRoomPlayer(m);
|
||||||
|
if (player.id !== owner.value.id) {
|
||||||
opponent.value = {
|
opponent.value = {
|
||||||
id: m.userInfo.id,
|
id: player.id,
|
||||||
name: m.userInfo.name,
|
name: player.name,
|
||||||
avatar: m.userInfo.avatar,
|
avatar: player.avatar,
|
||||||
rankLvl: m.userInfo.rankLvl,
|
rankLvl: player.rankLvl,
|
||||||
ready: !!m.userInfo.state,
|
vip: player.vip,
|
||||||
|
sVip: player.sVip,
|
||||||
|
ready: !!player.state,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -121,12 +281,12 @@ async function refreshRoomData() {
|
|||||||
(m) => m.userInfo.id === result.creator
|
(m) => m.userInfo.id === result.creator
|
||||||
);
|
);
|
||||||
if (ownerIndex !== -1) {
|
if (ownerIndex !== -1) {
|
||||||
players.value.push(result.members[ownerIndex].userInfo);
|
players.value.push(normalizeRoomPlayer(result.members[ownerIndex]));
|
||||||
} else {
|
} else {
|
||||||
players.value.push({});
|
players.value.push({});
|
||||||
}
|
}
|
||||||
result.members.forEach((m, index) => {
|
result.members.forEach((m, index) => {
|
||||||
if (ownerIndex !== index) players.value.push(m.userInfo);
|
if (ownerIndex !== index) players.value.push(normalizeRoomPlayer(m));
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
players.value = new Array(result.count).fill({});
|
players.value = new Array(result.count).fill({});
|
||||||
@@ -134,21 +294,47 @@ async function refreshRoomData() {
|
|||||||
}
|
}
|
||||||
if (timer.value) clearInterval(timer.value);
|
if (timer.value) clearInterval(timer.value);
|
||||||
// timer.value = setTimeout(refreshRoomData, 2000);
|
// timer.value = setTimeout(refreshRoomData, 2000);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReady = async () => {
|
const getReady = debounce(async () => {
|
||||||
|
if (readySubmitting.value) return;
|
||||||
|
readySubmitting.value = true;
|
||||||
|
try {
|
||||||
await getReadyAPI(roomNumber.value);
|
await getReadyAPI(roomNumber.value);
|
||||||
|
ready.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (isLimitError(error)) {
|
||||||
|
showLimitModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log("room ready error", error);
|
||||||
|
} finally {
|
||||||
|
readySubmitting.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeLimitModal = () => {
|
||||||
|
showLimitModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goVipPage = () => {
|
||||||
|
showLimitModal.value = false;
|
||||||
|
uni.navigateTo({
|
||||||
|
url: "/pages/member/be-vip",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshMembers = (members = []) => {
|
const refreshMembers = (members = []) => {
|
||||||
blueTeam.value = [];
|
blueTeam.value = [];
|
||||||
redTeam.value = [];
|
redTeam.value = [];
|
||||||
members.forEach((m, index) => {
|
members.forEach((m, index) => {
|
||||||
players.value[index] = { ...m.userInfo, groupType: m.groupType };
|
const player = { ...normalizeRoomPlayer(m), groupType: m.groupType };
|
||||||
|
players.value[index] = player;
|
||||||
if (m.groupType === 1) {
|
if (m.groupType === 1) {
|
||||||
blueTeam.value.push({ ...m.userInfo, groupType: 1 });
|
blueTeam.value.push({ ...player, groupType: 1 });
|
||||||
} else if (m.groupType === 2) {
|
} else if (m.groupType === 2) {
|
||||||
redTeam.value.push({ ...m.userInfo, groupType: 2 });
|
redTeam.value.push({ ...player, groupType: 2 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
for (let i = 0; i < room.value.count / 2; i++) {
|
for (let i = 0; i < room.value.count / 2; i++) {
|
||||||
@@ -190,17 +376,22 @@ async function onReceiveMessage(message) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if (message.type === MESSAGETYPESV2.AboutToStart) {
|
} else if (message.type === MESSAGETYPESV2.AboutToStart) {
|
||||||
|
if (navigationPending.value) return;
|
||||||
|
const matchId = message.matchId || message.id;
|
||||||
|
if (!matchId) return;
|
||||||
goBattle.value = true;
|
goBattle.value = true;
|
||||||
|
let url = "";
|
||||||
if (message.mode <= 3) {
|
if (message.mode <= 3) {
|
||||||
uni.setStorageSync("blue-team", message.teams[1].players || []);
|
uni.setStorageSync("blue-team", message.teams[1].players || []);
|
||||||
uni.setStorageSync("red-team", message.teams[2].players || []);
|
uni.setStorageSync("red-team", message.teams[2].players || []);
|
||||||
uni.redirectTo({
|
url = "/pages/team-battle/index?battleId=" + matchId;
|
||||||
url: "/pages/team-battle/index?battleId=" + message.matchId,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
uni.redirectTo({
|
url = "/pages/melee-battle?battleId=" + matchId;
|
||||||
url: "/pages/melee-battle?battleId=" + message.matchId,
|
}
|
||||||
});
|
try {
|
||||||
|
await redirectOnce(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("redirect to room battle error", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +486,15 @@ onShareAppMessage(() => {
|
|||||||
*/
|
*/
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
goBattle.value = false;
|
goBattle.value = false;
|
||||||
refreshRoomData();
|
navigationPending.value = false;
|
||||||
|
pageVisible = true;
|
||||||
|
const generation = ++foregroundGeneration;
|
||||||
|
if (pageMounted) void reconcileBattleRoom(generation);
|
||||||
|
});
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
pageVisible = false;
|
||||||
|
foregroundGeneration += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -319,6 +518,10 @@ onLoad(async (options) => {
|
|||||||
const stored = uni.getStorageSync(`targetSize_${roomNumber.value}`);
|
const stored = uni.getStorageSync(`targetSize_${roomNumber.value}`);
|
||||||
if (stored) targetSize.value = stored;
|
if (stored) targetSize.value = stored;
|
||||||
}
|
}
|
||||||
|
if (pageMounted && pageVisible) {
|
||||||
|
const generation = ++foregroundGeneration;
|
||||||
|
void reconcileBattleRoom(generation);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -330,6 +533,8 @@ onMounted(() => {
|
|||||||
keepScreenOn: true,
|
keepScreenOn: true,
|
||||||
});
|
});
|
||||||
uni.$on("socket-inbox", onReceiveMessage);
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
|
pageMounted = true;
|
||||||
|
if (pageVisible) void reconcileBattleRoom(foregroundGeneration);
|
||||||
// 页面加载完成 1 秒后根据靶纸尺寸播报对应语音;从结算页返回时跳过
|
// 页面加载完成 1 秒后根据靶纸尺寸播报对应语音;从结算页返回时跳过
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!skipTargetAudio.value) {
|
if (!skipTargetAudio.value) {
|
||||||
@@ -340,6 +545,9 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
pageMounted = false;
|
||||||
|
pageVisible = false;
|
||||||
|
foregroundGeneration += 1;
|
||||||
uni.setKeepScreenOn({
|
uni.setKeepScreenOn({
|
||||||
keepScreenOn: false,
|
keepScreenOn: false,
|
||||||
});
|
});
|
||||||
@@ -367,18 +575,36 @@ onBeforeUnmount(() => {
|
|||||||
<view v-if="owner.id" class="player" :style="{ transform: 'translateY(-60px)' }">
|
<view v-if="owner.id" class="player" :style="{ transform: 'translateY(-60px)' }">
|
||||||
<Avatar :src="owner.avatar" :size="60" />
|
<Avatar :src="owner.avatar" :size="60" />
|
||||||
<text>管理员</text>
|
<text>管理员</text>
|
||||||
<text>{{ owner.name }}</text>
|
<view
|
||||||
|
v-if="isMember(owner)"
|
||||||
|
:class="['room-player-name', ...getMemberNicknameClass(owner)]"
|
||||||
|
>
|
||||||
|
<text class="member-nickname__text">{{ owner.name }}</text>
|
||||||
|
<text v-if="owner.sVip === true" class="member-nickname__shine">
|
||||||
|
{{ owner.name }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-else class="room-player-name">{{ owner.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-else class="no-player" :style="{ transform: 'translateY(-60px)' }">
|
<view v-else class="no-player" :style="{ transform: 'translateY(-60px)' }">
|
||||||
<image src="../static/question-mark.png" mode="widthFix" />
|
<image src="../static/question-mark.png" mode="widthFix" />
|
||||||
</view>
|
</view>
|
||||||
<image src="../static/versus.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/versus.png" mode="widthFix" />
|
||||||
<view v-if="opponent.id" class="player" :style="{ transform: 'translateY(60px)' }">
|
<view v-if="opponent.id" class="player" :style="{ transform: 'translateY(60px)' }">
|
||||||
<Avatar :src="opponent.avatar" :size="60" />
|
<Avatar :src="opponent.avatar" :size="60" />
|
||||||
<text class="ready" :style="{ opacity: opponent.ready ? 1 : 0 }">
|
<text class="ready" :style="{ opacity: opponent.ready ? 1 : 0 }">
|
||||||
已准备
|
已准备
|
||||||
</text>
|
</text>
|
||||||
<text>{{ opponent.name }}</text>
|
<view
|
||||||
|
v-if="isMember(opponent)"
|
||||||
|
:class="['room-player-name', ...getMemberNicknameClass(opponent)]"
|
||||||
|
>
|
||||||
|
<text class="member-nickname__text">{{ opponent.name }}</text>
|
||||||
|
<text v-if="opponent.sVip === true" class="member-nickname__shine">
|
||||||
|
{{ opponent.name }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-else class="room-player-name">{{ opponent.name }}</text>
|
||||||
<button v-if="owner.id === user.id" hover-class="none" class="remove-player"
|
<button v-if="owner.id === user.id" hover-class="none" class="remove-player"
|
||||||
@click="() => removePlayer(opponent)">
|
@click="() => removePlayer(opponent)">
|
||||||
<image src="../static/close-white.png" mode="widthFix" />
|
<image src="../static/close-white.png" mode="widthFix" />
|
||||||
@@ -392,8 +618,8 @@ onBeforeUnmount(() => {
|
|||||||
<block v-if="room.battleType === 1 && room.count >= 4">
|
<block v-if="room.battleType === 1 && room.count >= 4">
|
||||||
<view class="all-players">
|
<view class="all-players">
|
||||||
<image src="https://static.shelingxingqiu.com/attachment/2025-08-13/dc0x1p59iab6cvbhqc.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/attachment/2025-08-13/dc0x1p59iab6cvbhqc.png" mode="widthFix" />
|
||||||
<image v-if="room.count === 4" src="../static/title-2v2.png" mode="widthFix" />
|
<image v-if="room.count === 4" src="https://static.shelingxingqiu.com/shootmini/static/title-2v2.png" mode="widthFix" />
|
||||||
<image v-if="room.count === 6" src="../static/title-3v3.png" mode="widthFix" />
|
<image v-if="room.count === 6" src="https://static.shelingxingqiu.com/shootmini/static/title-3v3.png" mode="widthFix" />
|
||||||
<view>
|
<view>
|
||||||
<view v-for="(item, index) in players" :key="index">
|
<view v-for="(item, index) in players" :key="index">
|
||||||
<Avatar v-if="item.id" :src="item.avatar" :size="36" />
|
<Avatar v-if="item.id" :src="item.avatar" :size="36" />
|
||||||
@@ -412,8 +638,17 @@ onBeforeUnmount(() => {
|
|||||||
<button hover-class="none" v-if="item.id === user.id" @click="chooseTeam(0)">
|
<button hover-class="none" v-if="item.id === user.id" @click="chooseTeam(0)">
|
||||||
<image src="../static/close-grey.png" mode="widthFix" />
|
<image src="../static/close-grey.png" mode="widthFix" />
|
||||||
</button>
|
</button>
|
||||||
<text class="truncate">{{ item.name || "我要加入" }}</text>
|
<view
|
||||||
<view v-if="item.id">
|
v-if="item.id && isMember(item)"
|
||||||
|
:class="['team-player-name', 'truncate', ...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="truncate">{{ item.name || "我要加入" }}</text>
|
||||||
|
<view v-if="item.id" class="team-player-avatar">
|
||||||
<Avatar :src="item.avatar" :size="36" />
|
<Avatar :src="item.avatar" :size="36" />
|
||||||
<text :style="{ opacity: !!item.state ? 1 : 0 }">已准备</text>
|
<text :style="{ opacity: !!item.state ? 1 : 0 }">已准备</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -424,14 +659,23 @@ onBeforeUnmount(() => {
|
|||||||
</view>
|
</view>
|
||||||
<view>
|
<view>
|
||||||
<view v-for="(item, index) in blueTeam" :key="index" class="choose-side-right-item">
|
<view v-for="(item, index) in blueTeam" :key="index" class="choose-side-right-item">
|
||||||
<view v-if="item.id">
|
<view v-if="item.id" class="team-player-avatar">
|
||||||
<Avatar :src="item.avatar" :size="36" />
|
<Avatar :src="item.avatar" :size="36" />
|
||||||
<text :style="{ opacity: !!item.state ? 1 : 0 }">已准备</text>
|
<text :style="{ opacity: !!item.state ? 1 : 0 }">已准备</text>
|
||||||
</view>
|
</view>
|
||||||
<button v-else hover-class="none" @click="chooseTeam(1)">
|
<button v-else hover-class="none" @click="chooseTeam(1)">
|
||||||
<image src="../static/add-grey.png" mode="widthFix" />
|
<image src="../static/add-grey.png" mode="widthFix" />
|
||||||
</button>
|
</button>
|
||||||
<text class="truncate">{{ item.name || "我要加入" }}</text>
|
<view
|
||||||
|
v-if="item.id && isMember(item)"
|
||||||
|
:class="['team-player-name', 'truncate', ...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="truncate">{{ item.name || "我要加入" }}</text>
|
||||||
<button hover-class="none" v-if="item.id === user.id" @click="chooseTeam(0)">
|
<button hover-class="none" v-if="item.id === user.id" @click="chooseTeam(0)">
|
||||||
<image src="../static/close-grey.png" mode="widthFix" />
|
<image src="../static/close-grey.png" mode="widthFix" />
|
||||||
</button>
|
</button>
|
||||||
@@ -443,7 +687,10 @@ onBeforeUnmount(() => {
|
|||||||
<PlayerSeats v-if="room.battleType === 2" :total="room.count || 10" :players="players"
|
<PlayerSeats v-if="room.battleType === 2" :total="room.count || 10" :players="players"
|
||||||
:removePlayer="removePlayer" :isOwner="owner.id === user.id" />
|
:removePlayer="removePlayer" :isOwner="owner.id === user.id" />
|
||||||
<view>
|
<view>
|
||||||
<SButton :disabled="!canClick" :onClick="() => $clickSound(getReady)">
|
<SButton
|
||||||
|
:disabled="!canClick || readySubmitting"
|
||||||
|
:onClick="() => { $clickSound(); return getReady(); }"
|
||||||
|
>
|
||||||
{{
|
{{
|
||||||
allReady.value
|
allReady.value
|
||||||
? "即将进入对局..."
|
? "即将进入对局..."
|
||||||
@@ -456,6 +703,14 @@ onBeforeUnmount(() => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
|
<ModalDialog
|
||||||
|
:show="showLimitModal"
|
||||||
|
:content="'今日约战次数已经用完\n开通会员可增加次数'"
|
||||||
|
cancelText="知道了"
|
||||||
|
confirmText="去开通"
|
||||||
|
:onCancel="closeLimitModal"
|
||||||
|
:onConfirm="goVipPage"
|
||||||
|
/>
|
||||||
<!-- 踢出玩家二次确认弹窗(不传 onClose,屏蔽 X 关闭按钮) -->
|
<!-- 踢出玩家二次确认弹窗(不传 onClose,屏蔽 X 关闭按钮) -->
|
||||||
<ScreenHint :show="showKickConfirm">
|
<ScreenHint :show="showKickConfirm">
|
||||||
<view class="kick-confirm">
|
<view class="kick-confirm">
|
||||||
@@ -554,11 +809,12 @@ onBeforeUnmount(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player>text:nth-child(3) {
|
.room-player-name {
|
||||||
margin-top: 6rpx;
|
margin-top: 6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player>text {
|
.player>text,
|
||||||
|
.room-player-name {
|
||||||
max-width: 100px;
|
max-width: 100px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -566,6 +822,12 @@ onBeforeUnmount(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.room-player-name .member-nickname__text,
|
||||||
|
.room-player-name .member-nickname__shine {
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.player .ready {
|
.player .ready {
|
||||||
background-color: #2c261fb3 !important;
|
background-color: #2c261fb3 !important;
|
||||||
border: 1rpx solid #a3793f66 !important;
|
border: 1rpx solid #a3793f66 !important;
|
||||||
@@ -813,12 +1075,18 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.choose-side-left-item>text,
|
.choose-side-left-item>text,
|
||||||
.choose-side-right-item>text {
|
.choose-side-right-item>text,
|
||||||
|
.team-player-name {
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
max-width: 100px;
|
max-width: 100px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.team-player-name .member-nickname__text,
|
||||||
|
.team-player-name .member-nickname__shine {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.choose-side-left-item>button:first-child,
|
.choose-side-left-item>button:first-child,
|
||||||
.choose-side-right-item>button:last-child {
|
.choose-side-right-item>button:last-child {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -855,15 +1123,13 @@ onBeforeUnmount(() => {
|
|||||||
width: 18px;
|
width: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choose-side-left-item>view,
|
.team-player-avatar {
|
||||||
.choose-side-right-item>view {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.choose-side-left-item>view>text,
|
.team-player-avatar>text {
|
||||||
.choose-side-right-item>view>text {
|
|
||||||
font-size: 16rpx;
|
font-size: 16rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
line-height: 26rpx;
|
line-height: 26rpx;
|
||||||
|
|||||||
@@ -1,258 +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 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 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 ? 'calc(100% - 10px)' : '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" />
|
|
||||||
</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>
|
|
||||||
<view class="my-orders" v-if="user.id">
|
|
||||||
<view @click="toOrderPage">
|
|
||||||
<text>我的订单</text>
|
|
||||||
<image src="../static/enter-arrow-blue.png" mode="widthFix" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<Signin :show="showModal" :onClose="() => (showModal = false)" />
|
|
||||||
</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>
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||||
|
import { onShow, onHide } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import SButton from "@/components/SButton.vue";
|
import SButton from "@/components/SButton.vue";
|
||||||
|
|
||||||
import { laserAimAPI, laserCloseAPI } from "@/apis";
|
import { aimRenewAPI, laserAimAPI, laserCloseAPI } from "@/apis";
|
||||||
import { MESSAGETYPES } from "@/constants";
|
import { MESSAGETYPES } from "@/constants";
|
||||||
// import audioManager from "@/audioManager";
|
// import audioManager from "@/audioManager";
|
||||||
|
|
||||||
@@ -23,8 +24,56 @@ const guides = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const done = ref(true);
|
const done = ref(true);
|
||||||
|
let aimRenewTimer = null;
|
||||||
|
let pageMounted = false;
|
||||||
|
let pageVisible = false;
|
||||||
|
let aimSessionVersion = 0;
|
||||||
|
|
||||||
|
const stopAimRenew = () => {
|
||||||
|
if (!aimRenewTimer) return;
|
||||||
|
clearInterval(aimRenewTimer);
|
||||||
|
aimRenewTimer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startAimRenew = () => {
|
||||||
|
stopAimRenew();
|
||||||
|
aimRenewTimer = setInterval(() => {
|
||||||
|
aimRenewAPI();
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAimSession = async (sessionVersion) => {
|
||||||
|
try {
|
||||||
|
await laserAimAPI();
|
||||||
|
} catch (error) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!pageMounted ||
|
||||||
|
!pageVisible ||
|
||||||
|
sessionVersion !== aimSessionVersion
|
||||||
|
) {
|
||||||
|
void laserCloseAPI().catch(() => {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startAimRenew();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopAimSession = (closeLaser = true) => {
|
||||||
|
const wasVisible = pageVisible;
|
||||||
|
pageVisible = false;
|
||||||
|
aimSessionVersion += 1;
|
||||||
|
stopAimRenew();
|
||||||
|
|
||||||
|
if (closeLaser && wasVisible) {
|
||||||
|
void laserCloseAPI().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onComplete = async () => {
|
const onComplete = async () => {
|
||||||
|
stopAimSession(false);
|
||||||
await laserCloseAPI();
|
await laserCloseAPI();
|
||||||
uni.navigateBack();
|
uni.navigateBack();
|
||||||
};
|
};
|
||||||
@@ -39,14 +88,26 @@ function onReceiveMessage(messages = []) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onShow(() => {
|
||||||
uni.$on("socket-inbox", onReceiveMessage);
|
pageVisible = true;
|
||||||
await laserAimAPI();
|
const sessionVersion = ++aimSessionVersion;
|
||||||
|
if (pageMounted) void openAimSession(sessionVersion);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(async () => {
|
onHide(() => {
|
||||||
|
stopAimSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
|
pageMounted = true;
|
||||||
|
if (pageVisible) void openAimSession(aimSessionVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
pageMounted = false;
|
||||||
|
stopAimSession();
|
||||||
uni.$off("socket-inbox", onReceiveMessage);
|
uni.$off("socket-inbox", onReceiveMessage);
|
||||||
await laserCloseAPI();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
cumulative: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
available: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="balance-panel">
|
||||||
|
<view class="balance-list">
|
||||||
|
<view class="balance-item">
|
||||||
|
<text class="balance-item__label">累计金币:</text>
|
||||||
|
<text class="balance-item__value">{{ cumulative }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="balance-list__line" />
|
||||||
|
<view class="balance-item">
|
||||||
|
<text class="balance-item__label">可兑换金币:</text>
|
||||||
|
<text class="balance-item__value">{{ available }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.balance-panel {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8rpx 28rpx 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-list {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 706rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
margin-top: 0;
|
||||||
|
border: 2rpx solid rgba(255, 217, 71, 0.25);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: rgba(255, 217, 71, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-item {
|
||||||
|
width: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-item__label {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-item__value {
|
||||||
|
color: #ffd947;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 42rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.balance-list__line {
|
||||||
|
width: 2rpx;
|
||||||
|
height: 28rpx;
|
||||||
|
background-color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin: 0 22rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
text: {
|
||||||
|
type: String,
|
||||||
|
default: "暂无金币获取记录。",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="empty-state">
|
||||||
|
<image
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/coin/empty-coin-record.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text>{{ text }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 280rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state > image {
|
||||||
|
width: 162rpx;
|
||||||
|
height: 190rpx;
|
||||||
|
margin-bottom: 26rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup>
|
||||||
|
import Header from "@/components/Header.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
onBack: {
|
||||||
|
type: Function,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="coin-header">
|
||||||
|
<Header title="" :onBack="onBack">
|
||||||
|
<template #title>
|
||||||
|
<view class="coin-header__title-group">
|
||||||
|
<text
|
||||||
|
:class="[
|
||||||
|
'coin-header__title',
|
||||||
|
subtitle ? 'coin-header__title--with-subtitle' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ title }}
|
||||||
|
</text>
|
||||||
|
<text v-if="subtitle" class="coin-header__subtitle">
|
||||||
|
{{ subtitle }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</Header>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.coin-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-header__title-group {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 430rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-header__title {
|
||||||
|
color: #e7ba80;
|
||||||
|
font-size: 30rpx;
|
||||||
|
line-height: 42rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-header__title--with-subtitle {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coin-header__subtitle {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
menus: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["select"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="quick-menu">
|
||||||
|
<view
|
||||||
|
v-for="item in menus"
|
||||||
|
:key="item.key"
|
||||||
|
class="quick-menu__item"
|
||||||
|
@click="emit('select', item)"
|
||||||
|
>
|
||||||
|
<image class="quick-menu__icon" :src="item.icon" mode="aspectFit" />
|
||||||
|
<text>{{ item.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quick-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 26rpx 46rpx 30rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-menu__item {
|
||||||
|
width: 142rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: #fae6bc;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-menu__icon {
|
||||||
|
width: 116rpx;
|
||||||
|
height: 116rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
records: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: "earning",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view
|
||||||
|
class="record-table"
|
||||||
|
:class="{ 'record-table--exchange': mode === 'exchange' }"
|
||||||
|
>
|
||||||
|
<view class="record-table__header">
|
||||||
|
<template v-if="mode === 'exchange'">
|
||||||
|
<text>兑换内容</text>
|
||||||
|
<text>兑换类型</text>
|
||||||
|
<text>时间</text>
|
||||||
|
<text>金币使用</text>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<text>时间</text>
|
||||||
|
<text>类型</text>
|
||||||
|
<text>金币获取</text>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="item in records"
|
||||||
|
:key="item.id"
|
||||||
|
class="record-table__row"
|
||||||
|
>
|
||||||
|
<template v-if="mode === 'exchange'">
|
||||||
|
<text class="record-table__exchange-content">{{ item.content }}</text>
|
||||||
|
<text class="record-table__exchange-type">{{ item.type }}</text>
|
||||||
|
<view class="record-table__time record-table__exchange-time">
|
||||||
|
<text>{{ item.date }}</text>
|
||||||
|
<text>{{ item.time }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="record-table__amount record-table__exchange-amount">
|
||||||
|
{{ item.amount }}
|
||||||
|
</text>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<view class="record-table__time">
|
||||||
|
<text>{{ item.date }}</text>
|
||||||
|
<text>{{ item.time }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="record-table__type">{{ item.type }}</text>
|
||||||
|
<text class="record-table__amount">{{ item.amount }}</text>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.record-table {
|
||||||
|
width: 670rpx;
|
||||||
|
margin: 20rpx auto 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__header,
|
||||||
|
.record-table__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__header {
|
||||||
|
height: 66rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__header > text {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-right: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__header > text:nth-child(1),
|
||||||
|
.record-table__time {
|
||||||
|
width: 182rpx;
|
||||||
|
flex: 0 0 182rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__header > text:nth-child(2),
|
||||||
|
.record-table__type {
|
||||||
|
width: 304rpx;
|
||||||
|
flex: 0 0 304rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__header > text:nth-child(3),
|
||||||
|
.record-table__amount {
|
||||||
|
width: 180rpx;
|
||||||
|
flex: 0 0 180rpx;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__row {
|
||||||
|
height: 86rpx;
|
||||||
|
border-top: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__row > view,
|
||||||
|
.record-table__row > text {
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-right: 2rpx solid rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__time {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__time > text:last-child {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__type {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__row > .record-table__amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24rpx;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table__exchange-content,
|
||||||
|
.record-table__exchange-type {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table--exchange .record-table__header > text:nth-child(1),
|
||||||
|
.record-table__exchange-content {
|
||||||
|
width: 286rpx;
|
||||||
|
flex: 0 0 286rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table--exchange .record-table__header > text:nth-child(2),
|
||||||
|
.record-table__exchange-type {
|
||||||
|
width: 110rpx;
|
||||||
|
flex: 0 0 110rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table--exchange .record-table__header > text:nth-child(3),
|
||||||
|
.record-table__exchange-time {
|
||||||
|
width: 160rpx;
|
||||||
|
flex: 0 0 160rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-table--exchange .record-table__header > text:nth-child(4),
|
||||||
|
.record-table__exchange-amount {
|
||||||
|
width: 110rpx;
|
||||||
|
flex: 0 0 110rpx;
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script setup>
|
||||||
|
const emit = defineEmits(["authorize"]);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="location-state">
|
||||||
|
<image
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/coin/location-permission.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text>授权获取你的定位,以便推荐附近门店。</text>
|
||||||
|
<button hover-class="none" @click="emit('authorize')">立即授权</button>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.location-state {
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 190rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-state > image {
|
||||||
|
width: 168rpx;
|
||||||
|
height: 190rpx;
|
||||||
|
margin-bottom: 52rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-state > button {
|
||||||
|
width: 360rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
margin-top: 44rpx;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
background-color: #ffd947;
|
||||||
|
color: #22222e;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 72rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.location-state > button::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
variant: {
|
||||||
|
type: String,
|
||||||
|
default: "gold",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="exchange-notice" :class="`exchange-notice--${variant}`">
|
||||||
|
<image
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/coin/icon-notice.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text>暂不支持线上兑换,请前往线下门店进行兑换。</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.exchange-notice {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 504rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
font-size: 20rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-notice--gold {
|
||||||
|
border: 2rpx solid rgba(255, 217, 71, 0.28);
|
||||||
|
background-color: rgba(255, 217, 71, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-notice--red {
|
||||||
|
width: 492rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
margin: 0 0 0 40rpx;
|
||||||
|
border: none;
|
||||||
|
background-color: rgba(255, 96, 96, 0.3);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exchange-notice > image {
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
margin-right: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
|
||||||
|
const DEFAULT_PRODUCT_IMAGE =
|
||||||
|
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-item.png";
|
||||||
|
const props = defineProps({
|
||||||
|
images: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const current = ref(0);
|
||||||
|
const slideImages = computed(() => {
|
||||||
|
const images = props.images.filter(
|
||||||
|
(image) => typeof image === "string" && image.trim()
|
||||||
|
);
|
||||||
|
return images.length ? images : [DEFAULT_PRODUCT_IMAGE];
|
||||||
|
});
|
||||||
|
|
||||||
|
const onChange = (event) => {
|
||||||
|
current.value = event.detail.current;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="hero-swiper">
|
||||||
|
<swiper class="hero-swiper__body" :duration="260" @change="onChange">
|
||||||
|
<swiper-item v-for="(image, index) in slideImages" :key="index">
|
||||||
|
<view class="hero-swiper__item">
|
||||||
|
<image
|
||||||
|
class="hero-swiper__background"
|
||||||
|
src="https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png"
|
||||||
|
mode="scaleToFill"
|
||||||
|
/>
|
||||||
|
<image class="hero-swiper__product" :src="image" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
|
</swiper-item>
|
||||||
|
</swiper>
|
||||||
|
<view class="hero-swiper__dots">
|
||||||
|
<view
|
||||||
|
v-for="(_, index) in slideImages"
|
||||||
|
:key="index"
|
||||||
|
class="hero-swiper__dot"
|
||||||
|
:class="{ 'hero-swiper__dot--active': current === index }"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hero-swiper,
|
||||||
|
.hero-swiper__body,
|
||||||
|
.hero-swiper__item {
|
||||||
|
position: relative;
|
||||||
|
width: 750rpx;
|
||||||
|
height: 750rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiper__background,
|
||||||
|
.hero-swiper__product {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiper__dots {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 20rpx;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiper__dot {
|
||||||
|
width: 12rpx;
|
||||||
|
height: 12rpx;
|
||||||
|
margin: 0 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(34, 34, 46, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-swiper__dot--active {
|
||||||
|
background-color: #ffd947;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const DEFAULT_PRODUCT_IMAGE =
|
||||||
|
"https://static.shelingxingqiu.com/shootmini/static/coin/product-hero-bg.png";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
product: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const productImage = computed(() => {
|
||||||
|
const image = props.product?.image;
|
||||||
|
return typeof image === "string" && image.trim()
|
||||||
|
? image
|
||||||
|
: DEFAULT_PRODUCT_IMAGE;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="product-card">
|
||||||
|
<view class="product-card__image-wrap">
|
||||||
|
<image class="product-card__image" :src="productImage" mode="aspectFit" />
|
||||||
|
</view>
|
||||||
|
<text class="product-card__name">{{ product.name }}</text>
|
||||||
|
<view class="product-card__footer">
|
||||||
|
<text>{{ product.cost }}金币</text>
|
||||||
|
<text class="product-card__divider">|</text>
|
||||||
|
<text>剩余{{ product.stock }}个</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-card {
|
||||||
|
width: 336rpx;
|
||||||
|
height: 418rpx;
|
||||||
|
padding: 10rpx 10rpx 14rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2rpx solid rgba(255, 217, 71, 0.1);
|
||||||
|
border-radius: 12rpx;
|
||||||
|
background-color: rgba(84, 67, 29, 0.2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__image-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 312rpx;
|
||||||
|
height: 312rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__name {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: #fff0c9;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__footer > text:first-child {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__footer > text:last-child {
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-card__divider {
|
||||||
|
margin: 0 10rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
store: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["select"]);
|
||||||
|
|
||||||
|
const selectStore = () => {
|
||||||
|
emit("select", props.store);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="store-card" hover-class="none" @click="selectStore">
|
||||||
|
<image class="store-card__photo" :src="store.image" mode="aspectFill" />
|
||||||
|
<text class="store-card__name">{{ store.name }}</text>
|
||||||
|
<view class="store-card__info">
|
||||||
|
<view class="store-card__row">
|
||||||
|
<text>地址:{{ store.address }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="store-card__row">
|
||||||
|
<text>电话:{{ store.phone }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="store-card__row">
|
||||||
|
<text>营业时间:{{ store.hours }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.store-card {
|
||||||
|
width: 650rpx;
|
||||||
|
margin: 22rpx auto 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card__photo {
|
||||||
|
width: 650rpx;
|
||||||
|
height: 324rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card__name {
|
||||||
|
display: block;
|
||||||
|
margin-top: 24rpx;
|
||||||
|
margin-left: 10rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 44rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card__info {
|
||||||
|
width: 584rpx;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
margin-left: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-card__row {
|
||||||
|
margin-top: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export const quickMenus = [
|
||||||
|
{
|
||||||
|
key: "rules",
|
||||||
|
label: "金币规则",
|
||||||
|
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-rules.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "earningRecords",
|
||||||
|
label: "获取明细",
|
||||||
|
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-earning-records.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exchangeRecords",
|
||||||
|
label: "兑换记录",
|
||||||
|
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-exchange-records.png",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "nearbyStores",
|
||||||
|
label: "附近门店",
|
||||||
|
icon: "https://static.shelingxingqiu.com/shootmini/static/coin/icon-nearby-store.png",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
|
import CoinRecordList from "./components/CoinRecordList.vue";
|
||||||
|
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||||
|
import { getGoldLogAPI } from "@/apis";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const storeId = ref("");
|
||||||
|
const earningRecords = ref([]);
|
||||||
|
const page = ref(0);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const noMore = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
const showEmpty = computed(() => loaded.value && !earningRecords.value.length);
|
||||||
|
|
||||||
|
const formatRecord = (item = {}) => {
|
||||||
|
const [date = "", time = ""] = String(item.createdAt || "").split(" ");
|
||||||
|
const amount = Number(item.amount) || 0;
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
type: item.remark || item.typeDesc || "-",
|
||||||
|
amount: amount > 0 ? `+${amount}` : String(amount),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 实际接口返回 totalCount/pageCount/pageSize,同时兼容接口文档中的旧字段。
|
||||||
|
const updatePaginationState = (result, list, nextPage) => {
|
||||||
|
const currentPage = Number(result?.page) || nextPage;
|
||||||
|
const pageCount = Number(result?.pageCount);
|
||||||
|
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||||
|
const responsePageSize = Number(result?.pageSize ?? result?.perPage) || PAGE_SIZE;
|
||||||
|
|
||||||
|
page.value = currentPage;
|
||||||
|
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||||
|
|
||||||
|
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||||
|
noMore.value = currentPage >= pageCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||||
|
noMore.value = earningRecords.value.length >= totalCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noMore.value = list.length < responsePageSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRecords = async ({ reset = false } = {}) => {
|
||||||
|
if (loading.value || (!reset && noMore.value)) return;
|
||||||
|
|
||||||
|
const nextPage = reset ? 1 : page.value + 1;
|
||||||
|
loading.value = true;
|
||||||
|
if (reset) noMore.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getGoldLogAPI({
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
type: 1,
|
||||||
|
storeId: storeId.value,
|
||||||
|
});
|
||||||
|
const list = Array.isArray(result?.list) ? result.list : [];
|
||||||
|
const mappedList = list.map(formatRecord);
|
||||||
|
earningRecords.value = reset
|
||||||
|
? mappedList
|
||||||
|
: earningRecords.value.concat(mappedList);
|
||||||
|
updatePaginationState(result, list, nextPage);
|
||||||
|
} catch (error) {
|
||||||
|
if (reset) {
|
||||||
|
earningRecords.value = [];
|
||||||
|
page.value = 0;
|
||||||
|
}
|
||||||
|
console.error("加载金币获取明细失败", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options = {}) => {
|
||||||
|
const currentStoreId = String(options.storeId || "");
|
||||||
|
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/coin/nearby-stores",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storeId.value = currentStoreId;
|
||||||
|
loadRecords({ reset: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadRecords">
|
||||||
|
<template #header>
|
||||||
|
<CoinHeader title="金币获取明细" />
|
||||||
|
</template>
|
||||||
|
<view class="records-page">
|
||||||
|
<CoinEmptyState v-if="showEmpty" />
|
||||||
|
<CoinRecordList v-else :records="earningRecords" />
|
||||||
|
<view
|
||||||
|
v-if="loading || (noMore && earningRecords.length)"
|
||||||
|
class="records-page__status"
|
||||||
|
>
|
||||||
|
<text>{{ loading ? "加载中..." : "没有更多了" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.records-page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-page__status {
|
||||||
|
padding: 24rpx 0 32rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
|
import CoinRecordList from "./components/CoinRecordList.vue";
|
||||||
|
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||||
|
import { getGoldLogAPI } from "@/apis";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const storeId = ref("");
|
||||||
|
const exchangeRecords = ref([]);
|
||||||
|
const page = ref(0);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const noMore = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
const showEmpty = computed(() => loaded.value && !exchangeRecords.value.length);
|
||||||
|
|
||||||
|
const formatRecord = (item = {}) => {
|
||||||
|
const [date = "", time = ""] = String(item.createdAt || "").split(" ");
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
content: item.remark || "-",
|
||||||
|
type: item.typeDesc || "-",
|
||||||
|
date,
|
||||||
|
time,
|
||||||
|
amount: String(Number(item.amount) || 0),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 实际接口返回 totalCount/pageCount/pageSize,同时兼容接口文档中的旧字段。
|
||||||
|
const updatePaginationState = (result, list, nextPage) => {
|
||||||
|
const currentPage = Number(result?.page) || nextPage;
|
||||||
|
const pageCount = Number(result?.pageCount);
|
||||||
|
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||||
|
const responsePageSize = Number(result?.pageSize ?? result?.perPage) || PAGE_SIZE;
|
||||||
|
|
||||||
|
page.value = currentPage;
|
||||||
|
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||||
|
|
||||||
|
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||||
|
noMore.value = currentPage >= pageCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||||
|
noMore.value = exchangeRecords.value.length >= totalCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noMore.value = list.length < responsePageSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRecords = async ({ reset = false } = {}) => {
|
||||||
|
if (loading.value || (!reset && noMore.value)) return;
|
||||||
|
|
||||||
|
const nextPage = reset ? 1 : page.value + 1;
|
||||||
|
loading.value = true;
|
||||||
|
if (reset) noMore.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getGoldLogAPI({
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
type: 2,
|
||||||
|
storeId: storeId.value,
|
||||||
|
});
|
||||||
|
const list = Array.isArray(result?.list) ? result.list : [];
|
||||||
|
const mappedList = list.map(formatRecord);
|
||||||
|
exchangeRecords.value = reset
|
||||||
|
? mappedList
|
||||||
|
: exchangeRecords.value.concat(mappedList);
|
||||||
|
updatePaginationState(result, list, nextPage);
|
||||||
|
} catch (error) {
|
||||||
|
if (reset) {
|
||||||
|
exchangeRecords.value = [];
|
||||||
|
page.value = 0;
|
||||||
|
}
|
||||||
|
console.error("加载金币兑换记录失败", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options = {}) => {
|
||||||
|
const currentStoreId = String(options.storeId || "");
|
||||||
|
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/coin/nearby-stores",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storeId.value = currentStoreId;
|
||||||
|
loadRecords({ reset: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadRecords">
|
||||||
|
<template #header>
|
||||||
|
<CoinHeader title="金币兑换记录" />
|
||||||
|
</template>
|
||||||
|
<view class="records-page">
|
||||||
|
<CoinEmptyState v-if="showEmpty" text="暂无金币兑换记录。" />
|
||||||
|
<CoinRecordList v-else mode="exchange" :records="exchangeRecords" />
|
||||||
|
<view
|
||||||
|
v-if="loading || (noMore && exchangeRecords.length)"
|
||||||
|
class="records-page__status"
|
||||||
|
>
|
||||||
|
<text>{{ loading ? "加载中..." : "没有更多了" }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.records-page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.records-page__status {
|
||||||
|
padding: 24rpx 0 32rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
|
import CoinBalancePanel from "./components/CoinBalancePanel.vue";
|
||||||
|
import CoinQuickMenu from "./components/CoinQuickMenu.vue";
|
||||||
|
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||||
|
import RewardProductCard from "./components/RewardProductCard.vue";
|
||||||
|
import { getGiftListAPI, getMyGoldAPI } from "@/apis";
|
||||||
|
import { quickMenus } from "./data";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const coinSummary = ref({
|
||||||
|
cumulative: 0,
|
||||||
|
available: 0,
|
||||||
|
});
|
||||||
|
const selectedStoreId = ref("");
|
||||||
|
const selectedStoreName = ref("");
|
||||||
|
const rewardProducts = ref([]);
|
||||||
|
const productPage = ref(0);
|
||||||
|
const productTotal = ref(0);
|
||||||
|
const productLoading = ref(false);
|
||||||
|
const productNoMore = ref(false);
|
||||||
|
|
||||||
|
const loadCoinSummary = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getMyGoldAPI(selectedStoreId.value);
|
||||||
|
coinSummary.value = {
|
||||||
|
cumulative: Number(result?.totalGold) || 0,
|
||||||
|
available: Number(result?.usableGold) || 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载金币统计失败", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadProducts = async ({ reset = false } = {}) => {
|
||||||
|
if (productLoading.value || (!reset && productNoMore.value)) return;
|
||||||
|
|
||||||
|
const nextPage = reset ? 1 : productPage.value + 1;
|
||||||
|
productLoading.value = true;
|
||||||
|
if (reset) productNoMore.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getGiftListAPI({
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
storeId: selectedStoreId.value,
|
||||||
|
});
|
||||||
|
const list = Array.isArray(result?.list) ? result.list : [];
|
||||||
|
const mappedList = list.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || "",
|
||||||
|
cost: Number(item.coin_price) || 0,
|
||||||
|
stock: Number(item.stock) || 0,
|
||||||
|
image: item.cover_image || "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
rewardProducts.value = reset
|
||||||
|
? mappedList
|
||||||
|
: rewardProducts.value.concat(mappedList);
|
||||||
|
productPage.value = Number(result?.page) || nextPage;
|
||||||
|
const pageCount = Number(result?.pageCount ?? result?.page_count);
|
||||||
|
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||||
|
const responsePageSize =
|
||||||
|
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
|
||||||
|
productTotal.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||||
|
|
||||||
|
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||||
|
productNoMore.value = productPage.value >= pageCount;
|
||||||
|
} else if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||||
|
productNoMore.value = rewardProducts.value.length >= totalCount;
|
||||||
|
} else {
|
||||||
|
productNoMore.value = list.length < responsePageSize;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (reset) {
|
||||||
|
rewardProducts.value = [];
|
||||||
|
productPage.value = 0;
|
||||||
|
}
|
||||||
|
console.error("加载礼品列表失败", error);
|
||||||
|
} finally {
|
||||||
|
productLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuRoutes = {
|
||||||
|
rules: "/pages/coin/rules",
|
||||||
|
earningRecords: "/pages/coin/earning-records",
|
||||||
|
exchangeRecords: "/pages/coin/exchange-records",
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStorePageUrl = (path, extraQuery = "") => {
|
||||||
|
const query = [
|
||||||
|
`storeId=${encodeURIComponent(selectedStoreId.value)}`,
|
||||||
|
`storeName=${encodeURIComponent(selectedStoreName.value)}`,
|
||||||
|
extraQuery,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("&");
|
||||||
|
return `${path}?${query}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const decodeRouteText = (value = "") => {
|
||||||
|
const text = String(value);
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(text.replace(/\+/g, " "));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("门店名称解码失败", error);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMenuSelect = (menu) => {
|
||||||
|
if (menu.key === "nearbyStores") {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/coin/nearby-stores",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = menuRoutes[menu.key];
|
||||||
|
if (!url) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "功能开发中",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.navigateTo({
|
||||||
|
url: buildStorePageUrl(url),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const toProductDetail = (product) => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: buildStorePageUrl(
|
||||||
|
"/pages/coin/product-detail",
|
||||||
|
`id=${encodeURIComponent(product.id)}`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options = {}) => {
|
||||||
|
const storeId = String(options.storeId || "");
|
||||||
|
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/coin/nearby-stores",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedStoreId.value = storeId;
|
||||||
|
selectedStoreName.value = decodeRouteText(options.storeName);
|
||||||
|
loadCoinSummary();
|
||||||
|
loadProducts({ reset: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadProducts">
|
||||||
|
<template #header>
|
||||||
|
<CoinHeader title="我的金币" :subtitle="selectedStoreName" />
|
||||||
|
</template>
|
||||||
|
<view class="coin-page">
|
||||||
|
<CoinBalancePanel
|
||||||
|
:cumulative="coinSummary.cumulative"
|
||||||
|
:available="coinSummary.available"
|
||||||
|
/>
|
||||||
|
<CoinQuickMenu :menus="quickMenus" @select="onMenuSelect" />
|
||||||
|
<OfflineExchangeNotice />
|
||||||
|
<view class="reward-section">
|
||||||
|
<view class="reward-grid">
|
||||||
|
<view
|
||||||
|
v-for="product in rewardProducts"
|
||||||
|
:key="product.id"
|
||||||
|
class="reward-grid__item"
|
||||||
|
@click="toProductDetail(product)"
|
||||||
|
>
|
||||||
|
<RewardProductCard :product="product" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.coin-page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-section {
|
||||||
|
padding: 20rpx 28rpx 40rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-grid__item {
|
||||||
|
width: 336rpx;
|
||||||
|
margin-right: 22rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-grid__item:nth-child(2n) {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
|
import LocationPermissionState from "./components/LocationPermissionState.vue";
|
||||||
|
import StoreCard from "./components/StoreCard.vue";
|
||||||
|
import { getNearbyStoresAPI } from "@/apis";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const DEFAULT_STORE_IMAGE =
|
||||||
|
"https://static.shelingxingqiu.com/shootmini/static/coin/store-photo.png";
|
||||||
|
const showPermissionState = ref(false);
|
||||||
|
const stores = ref([]);
|
||||||
|
const location = ref(null);
|
||||||
|
const page = ref(0);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const noMore = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
const getCurrentLocation = () =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
uni.getLocation({
|
||||||
|
type: "gcj02",
|
||||||
|
success: resolve,
|
||||||
|
fail: reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStore = (item = {}) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name || "",
|
||||||
|
address: item.address || "",
|
||||||
|
phone: item.phone || "",
|
||||||
|
hours: item.businessHours || "",
|
||||||
|
image: item.coverImage || DEFAULT_STORE_IMAGE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadStores = async ({ reset = false } = {}) => {
|
||||||
|
if (!location.value || loading.value || (!reset && noMore.value)) return;
|
||||||
|
|
||||||
|
const nextPage = reset ? 1 : page.value + 1;
|
||||||
|
loading.value = true;
|
||||||
|
if (reset) noMore.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getNearbyStoresAPI({
|
||||||
|
...location.value,
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const list = Array.isArray(result?.list) ? result.list : [];
|
||||||
|
const mappedList = list.map(mapStore);
|
||||||
|
stores.value = reset ? mappedList : stores.value.concat(mappedList);
|
||||||
|
page.value = Number(result?.page) || nextPage;
|
||||||
|
const pageCount = Number(result?.pageCount);
|
||||||
|
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||||
|
const responsePageSize = Number(result?.pageSize) || PAGE_SIZE;
|
||||||
|
total.value = Number.isFinite(totalCount) ? totalCount : 0;
|
||||||
|
|
||||||
|
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||||
|
noMore.value = page.value >= pageCount;
|
||||||
|
} else if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||||
|
noMore.value = stores.value.length >= totalCount;
|
||||||
|
} else {
|
||||||
|
noMore.value = list.length < responsePageSize;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (reset) {
|
||||||
|
stores.value = [];
|
||||||
|
page.value = 0;
|
||||||
|
}
|
||||||
|
console.error("加载附近门店失败", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const locateAndLoadStores = async () => {
|
||||||
|
try {
|
||||||
|
const position = await getCurrentLocation();
|
||||||
|
location.value = {
|
||||||
|
longitude: position.longitude,
|
||||||
|
latitude: position.latitude,
|
||||||
|
};
|
||||||
|
showPermissionState.value = false;
|
||||||
|
await loadStores({ reset: true });
|
||||||
|
} catch (error) {
|
||||||
|
showPermissionState.value = true;
|
||||||
|
loaded.value = true;
|
||||||
|
console.error("获取定位失败", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const authorizeLocation = () => {
|
||||||
|
uni.openSetting({
|
||||||
|
success: locateAndLoadStores,
|
||||||
|
fail: locateAndLoadStores,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择门店后替换当前页面,返回时直接回到进入金币模块前的页面。
|
||||||
|
const selectStore = (store) => {
|
||||||
|
const storeId = String(store?.id || "");
|
||||||
|
if (!/^\d+$/.test(storeId) || Number(storeId) <= 0) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "门店信息无效",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeName = encodeURIComponent(store?.name || "");
|
||||||
|
uni.redirectTo({
|
||||||
|
url: `/pages/coin/index?storeId=${encodeURIComponent(storeId)}&storeName=${storeName}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad(locateAndLoadStores);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadStores">
|
||||||
|
<template #header>
|
||||||
|
<CoinHeader title="附近门店" />
|
||||||
|
</template>
|
||||||
|
<view class="stores-page">
|
||||||
|
<LocationPermissionState
|
||||||
|
v-if="showPermissionState"
|
||||||
|
@authorize="authorizeLocation"
|
||||||
|
/>
|
||||||
|
<template v-else>
|
||||||
|
<StoreCard
|
||||||
|
v-for="store in stores"
|
||||||
|
:key="store.id"
|
||||||
|
:store="store"
|
||||||
|
@select="selectStore"
|
||||||
|
/>
|
||||||
|
<text v-if="loaded && !stores.length" class="stores-page__more">
|
||||||
|
附近暂无门店~
|
||||||
|
</text>
|
||||||
|
<text v-else-if="noMore" class="stores-page__more">没有更多门店了~</text>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stores-page {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
padding-bottom: 54rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stores-page__more {
|
||||||
|
display: block;
|
||||||
|
width: 650rpx;
|
||||||
|
margin: 26rpx auto 0;
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 36rpx;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
|
import ProductHeroSwiper from "./components/ProductHeroSwiper.vue";
|
||||||
|
import OfflineExchangeNotice from "./components/OfflineExchangeNotice.vue";
|
||||||
|
import { getGiftDetailAPI } from "@/apis";
|
||||||
|
|
||||||
|
const productDetail = ref({
|
||||||
|
name: "",
|
||||||
|
coinPrice: 0,
|
||||||
|
stock: 0,
|
||||||
|
images: [],
|
||||||
|
descriptions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadProductDetail = async (id) => {
|
||||||
|
try {
|
||||||
|
const result = await getGiftDetailAPI(id);
|
||||||
|
const images = Array.isArray(result?.images)
|
||||||
|
? result.images
|
||||||
|
.slice()
|
||||||
|
.sort((first, second) =>
|
||||||
|
(Number(first?.sort_order) || 0) - (Number(second?.sort_order) || 0)
|
||||||
|
)
|
||||||
|
.map((item) => item?.image_url)
|
||||||
|
.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
productDetail.value = {
|
||||||
|
...productDetail.value,
|
||||||
|
name: result?.name || "",
|
||||||
|
coinPrice: Number(result?.coin_price) || 0,
|
||||||
|
stock: Number(result?.stock) || 0,
|
||||||
|
images,
|
||||||
|
descriptions: result?.description
|
||||||
|
? String(result.description).split(/\r?\n/).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("加载礼品详情失败", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options = {}) => {
|
||||||
|
const id = Number(options.id);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "商品参数无效",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProductDetail(id);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :bgType="6" :isHome="true">
|
||||||
|
<template #header>
|
||||||
|
<CoinHeader title="商品详情" />
|
||||||
|
</template>
|
||||||
|
<view class="product-detail">
|
||||||
|
<ProductHeroSwiper
|
||||||
|
:images="productDetail.images"
|
||||||
|
/>
|
||||||
|
<view class="product-detail__summary">
|
||||||
|
<text class="product-detail__name">{{ productDetail.name }}</text>
|
||||||
|
<view class="product-detail__balance">
|
||||||
|
<text>金币:</text>
|
||||||
|
<text class="product-detail__amount">{{ productDetail.coinPrice }}</text>
|
||||||
|
<text>(剩余{{ productDetail.stock }}个)</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<OfflineExchangeNotice variant="red" />
|
||||||
|
<view class="product-detail__content">
|
||||||
|
<text
|
||||||
|
v-for="(description, index) in productDetail.descriptions"
|
||||||
|
:key="index"
|
||||||
|
class="product-detail__paragraph"
|
||||||
|
>
|
||||||
|
{{ description }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-detail {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
padding-bottom: 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #22222e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__summary {
|
||||||
|
padding: 30rpx 40rpx 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__name {
|
||||||
|
color: #ffd947;
|
||||||
|
font-size: 52rpx;
|
||||||
|
line-height: 74rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__balance {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__amount {
|
||||||
|
margin-right: 6rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 36rpx;
|
||||||
|
line-height: 50rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__content {
|
||||||
|
padding: 38rpx 40rpx 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 2rpx solid rgba(255, 217, 71, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.product-detail__paragraph {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import CoinHeader from "./components/CoinHeader.vue";
|
||||||
|
import CoinEmptyState from "./components/CoinEmptyState.vue";
|
||||||
|
import { getStoreGoldRuleListAPI } from "@/apis";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
const storeId = ref("");
|
||||||
|
const coinRules = ref([]);
|
||||||
|
const page = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const noMore = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
const showEmpty = computed(() => loaded.value && !coinRules.value.length);
|
||||||
|
|
||||||
|
const updatePaginationState = (result, list, nextPage) => {
|
||||||
|
const currentPage = Number(result?.page) || nextPage;
|
||||||
|
const pageCount = Number(result?.pageCount ?? result?.page_count);
|
||||||
|
const totalCount = Number(result?.totalCount ?? result?.total);
|
||||||
|
const responsePageSize =
|
||||||
|
Number(result?.pageSize ?? result?.page_size) || PAGE_SIZE;
|
||||||
|
|
||||||
|
page.value = currentPage;
|
||||||
|
if (Number.isFinite(pageCount) && pageCount >= 0) {
|
||||||
|
noMore.value = currentPage >= pageCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number.isFinite(totalCount) && totalCount >= 0) {
|
||||||
|
noMore.value = coinRules.value.length >= totalCount;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noMore.value = list.length < responsePageSize;
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRules = async ({ reset = false } = {}) => {
|
||||||
|
if (loading.value || (!reset && noMore.value)) return;
|
||||||
|
|
||||||
|
const nextPage = reset ? 1 : page.value + 1;
|
||||||
|
loading.value = true;
|
||||||
|
if (reset) noMore.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getStoreGoldRuleListAPI({
|
||||||
|
storeId: storeId.value,
|
||||||
|
page: nextPage,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const list = Array.isArray(result?.list) ? result.list : [];
|
||||||
|
const mappedList = list.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
content: item.content || "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
coinRules.value = reset ? mappedList : coinRules.value.concat(mappedList);
|
||||||
|
updatePaginationState(result, list, nextPage);
|
||||||
|
} catch (error) {
|
||||||
|
if (reset) {
|
||||||
|
coinRules.value = [];
|
||||||
|
page.value = 0;
|
||||||
|
}
|
||||||
|
console.error("加载金币规则失败", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
loaded.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options = {}) => {
|
||||||
|
const currentStoreId = String(options.storeId || "");
|
||||||
|
if (!/^\d+$/.test(currentStoreId) || Number(currentStoreId) <= 0) {
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/coin/nearby-stores",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
storeId.value = currentStoreId;
|
||||||
|
loadRules({ reset: true });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :bgType="6" :isHome="true" @scrolltolower="loadRules">
|
||||||
|
<template #header>
|
||||||
|
<CoinHeader title="金币规则" />
|
||||||
|
</template>
|
||||||
|
<view class="rules-page">
|
||||||
|
<CoinEmptyState v-if="showEmpty" text="暂无金币规则。" />
|
||||||
|
<template v-else>
|
||||||
|
<view
|
||||||
|
v-for="rule in coinRules"
|
||||||
|
:key="rule.id"
|
||||||
|
class="rules-page__item"
|
||||||
|
>
|
||||||
|
<rich-text class="rules-page__content" :nodes="rule.content" />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<view v-if="loading" class="rules-page__status">
|
||||||
|
<text>加载中...</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rules-page {
|
||||||
|
width: 100%;
|
||||||
|
padding: 28rpx 38rpx 60rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-page__item {
|
||||||
|
margin-bottom: 26rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-page__content {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-page__status {
|
||||||
|
padding: 12rpx 0 20rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import SButton from "@/components/SButton.vue";
|
import SButton from "@/components/SButton.vue";
|
||||||
|
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||||
|
|
||||||
import { capsuleHeight } from "@/util";
|
import { capsuleHeight } from "@/util";
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ const onScrollView = (e) => {
|
|||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
<DeviceChargingDialog />
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import ModalDialog from "@/components/ModalDialog.vue";
|
||||||
|
import SButton from "@/components/SButton.vue";
|
||||||
|
import { unbindDeviceByQrcodeIdAPI } from "@/apis";
|
||||||
|
|
||||||
|
const deviceQrcodeId = ref("");
|
||||||
|
const showConfirmDialog = ref(false);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
const isTestEnvironment = ref(false);
|
||||||
|
|
||||||
|
// 提交前移除用户输入中的所有空白字符,兼容复制粘贴的设备编号。
|
||||||
|
const normalizeDeviceQrcodeId = () =>
|
||||||
|
String(deviceQrcodeId.value ?? "").replace(/\s+/g, "");
|
||||||
|
|
||||||
|
const getEnvVersion = () => {
|
||||||
|
try {
|
||||||
|
return uni.getAccountInfoSync?.().miniProgram?.envVersion || "develop";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取小程序环境失败", error);
|
||||||
|
return "develop";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateDeviceQrcodeId = () => {
|
||||||
|
const normalizedId = normalizeDeviceQrcodeId();
|
||||||
|
deviceQrcodeId.value = normalizedId;
|
||||||
|
const numericId = Number(normalizedId);
|
||||||
|
|
||||||
|
if (!/^\d+$/.test(normalizedId) || !Number.isSafeInteger(numericId) || numericId <= 0) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "请输入正确的设备编号",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return numericId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openConfirmDialog = () => {
|
||||||
|
if (!isTestEnvironment.value || validateDeviceQrcodeId() === null) return;
|
||||||
|
showConfirmDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeConfirmDialog = () => {
|
||||||
|
if (isSubmitting.value) return;
|
||||||
|
showConfirmDialog.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmUnbind = async () => {
|
||||||
|
if (isSubmitting.value) return;
|
||||||
|
const id = validateDeviceQrcodeId();
|
||||||
|
if (id === null) {
|
||||||
|
showConfirmDialog.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await unbindDeviceByQrcodeIdAPI(id);
|
||||||
|
showConfirmDialog.value = false;
|
||||||
|
deviceQrcodeId.value = "";
|
||||||
|
uni.showToast({
|
||||||
|
title: "解绑成功",
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("测试解绑设备失败", error);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad(() => {
|
||||||
|
if (getEnvVersion() !== "release") {
|
||||||
|
isTestEnvironment.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.showToast({
|
||||||
|
title: "该功能仅用于测试环境",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.navigateBack();
|
||||||
|
}, 1200);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container title="解绑设备" :scroll="false">
|
||||||
|
<view v-if="isTestEnvironment" class="unbind-page">
|
||||||
|
<view class="unbind-form">
|
||||||
|
<input
|
||||||
|
v-model="deviceQrcodeId"
|
||||||
|
class="device-input"
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入设备编号"
|
||||||
|
placeholder-class="device-input-placeholder"
|
||||||
|
/>
|
||||||
|
<SButton
|
||||||
|
width="100%"
|
||||||
|
:rounded="40"
|
||||||
|
:onClick="openConfirmDialog"
|
||||||
|
>解绑</SButton>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<ModalDialog
|
||||||
|
:show="showConfirmDialog"
|
||||||
|
content="是否解绑该设备(仅用于测试环境)"
|
||||||
|
cancelText="取消"
|
||||||
|
confirmText="解绑"
|
||||||
|
:onCancel="closeConfirmDialog"
|
||||||
|
:onConfirm="confirmUnbind"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.unbind-page {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 72rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unbind-form {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border: 2rpx solid rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: 44rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.device-input-placeholder) {
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+241
-64
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||||
import Guide from "@/components/Guide.vue";
|
import Guide from "@/components/Guide.vue";
|
||||||
import SButton from "@/components/SButton.vue";
|
import SButton from "@/components/SButton.vue";
|
||||||
import Swiper from "@/components/Swiper.vue";
|
import Swiper from "@/components/Swiper.vue";
|
||||||
@@ -15,23 +15,32 @@ import BubbleTip from "@/components/BubbleTip.vue";
|
|||||||
import audioManager from "@/audioManager";
|
import audioManager from "@/audioManager";
|
||||||
import {
|
import {
|
||||||
createPractiseAPI,
|
createPractiseAPI,
|
||||||
startPractiseAPI,
|
|
||||||
endPractiseAPI,
|
|
||||||
getPractiseAPI,
|
getPractiseAPI,
|
||||||
|
startPractiseAPI,
|
||||||
|
laserAimAPI,
|
||||||
|
laserCloseAPI,
|
||||||
} from "@/apis";
|
} from "@/apis";
|
||||||
|
import { connectMatchWebSocket, closeMatchWebSocket } from "@/matchWebsocket";
|
||||||
import { sharePractiseData } from "@/canvas";
|
import { sharePractiseData } from "@/canvas";
|
||||||
import { wxShare, debounce } from "@/util";
|
import {
|
||||||
|
wxShare,
|
||||||
|
debounce,
|
||||||
|
getDistanceCheckAudioKey,
|
||||||
|
getShootValidation,
|
||||||
|
} from "@/util";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user } = storeToRefs(store);
|
const { user, device } = storeToRefs(store);
|
||||||
const scores = ref([]);
|
const scores = ref([]);
|
||||||
|
const isSvip = ref(false);
|
||||||
const step = ref(0);
|
const step = ref(0);
|
||||||
const total = 12;
|
const total = 12;
|
||||||
const stepButtonTexts = [
|
const stepButtonTexts = [
|
||||||
"开始",
|
"开始",
|
||||||
"进入下一个任务",
|
"进入下一个任务",
|
||||||
|
"我已校准",
|
||||||
"进入下一个任务",
|
"进入下一个任务",
|
||||||
"我准备好了,开始",
|
"我准备好了,开始",
|
||||||
"",
|
"",
|
||||||
@@ -43,8 +52,13 @@ const practiseResult = ref({});
|
|||||||
const btnDisabled = ref(false);
|
const btnDisabled = ref(false);
|
||||||
const practiseId = ref("");
|
const practiseId = ref("");
|
||||||
const showGuide = ref(false);
|
const showGuide = ref(false);
|
||||||
|
const laserActive = ref(false);
|
||||||
|
const guideSwiperIndex = ref(0);
|
||||||
|
const sharing = ref(false);
|
||||||
|
const RESULT_TIP_CDN = "https://static.shelingxingqiu.com/shootmini/static";
|
||||||
|
|
||||||
const guideImages = [
|
const guideImages = [
|
||||||
|
"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/dg9ev0wwdpgwt9e6du.png",
|
||||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wvv9sw4zioqk.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/dg9ev0ww3khaycallu.png",
|
||||||
@@ -54,33 +68,102 @@ const guideImages = [
|
|||||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwr6hfjhyfn5.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) => {
|
const onSwiperIndexChange = (index) => {
|
||||||
if (index + 1 === guideImages.length) {
|
guideSwiperIndex.value = index;
|
||||||
showGuide.value = true;
|
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 createPractise = async (arrows) => {
|
||||||
const result = await createPractiseAPI(arrows, 1);
|
closeMatchWebSocket({ reason: "practice-recreate" });
|
||||||
|
const result = await createPractiseAPI(
|
||||||
|
arrows,
|
||||||
|
120,
|
||||||
|
2,
|
||||||
|
device.value.deviceId
|
||||||
|
);
|
||||||
if (result) practiseId.value = result.id;
|
if (result) practiseId.value = result.id;
|
||||||
|
if (!result?.serverAddr) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "练习连接信息异常,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
connectMatchWebSocket({
|
||||||
|
serverAddr: result.serverAddr,
|
||||||
|
matchId: result.id,
|
||||||
|
userId: user.value.id,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onOver = async () => {
|
const onOver = async (message) => {
|
||||||
practiseResult.value = await getPractiseAPI(practiseId.value);
|
practiseResult.value = Array.isArray(message?.details)
|
||||||
|
? message
|
||||||
|
: await getPractiseAPI(practiseId.value);
|
||||||
start.value = false;
|
start.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const beginPractise = async () => {
|
||||||
|
if (!practiseId.value) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "练习未创建,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await startPractiseAPI(practiseId.value);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
async function onReceiveMessage(msg) {
|
async function onReceiveMessage(msg) {
|
||||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||||
scores.value = msg.details;
|
isSvip.value = msg.sVip === true;
|
||||||
|
scores.value = Array.isArray(msg.details) ? msg.details : scores.value;
|
||||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||||
setTimeout(onOver, 1500);
|
setTimeout(() => onOver(msg), 1500);
|
||||||
} else if (msg.type === MESSAGETYPESV2.TestDistance) {
|
} else if (msg.type === MESSAGETYPESV2.TestDistance && step.value === 3) {
|
||||||
if (msg.shootData.distance / 100 >= 5) {
|
const result = getShootValidation(msg.shootData);
|
||||||
audioManager.play("距离合格");
|
const isQualified = result.distanceOk && result.targetOk;
|
||||||
btnDisabled.value = false;
|
audioManager.play(getDistanceCheckAudioKey(msg.shootData));
|
||||||
showGuide.value = true;
|
btnDisabled.value = !isQualified;
|
||||||
} else audioManager.play("距离不足");
|
showGuide.value = isQualified;
|
||||||
}
|
}
|
||||||
// messages.forEach((msg) => {
|
// messages.forEach((msg) => {
|
||||||
// if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
// if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||||
@@ -98,11 +181,23 @@ async function onReceiveMessage(msg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onClickShare = debounce(async () => {
|
const onClickShare = debounce(async () => {
|
||||||
|
if (sharing.value) return;
|
||||||
|
sharing.value = true;
|
||||||
|
try {
|
||||||
await sharePractiseData("shareCanvas", 1, user.value, practiseResult.value);
|
await sharePractiseData("shareCanvas", 1, user.value, practiseResult.value);
|
||||||
await wxShare("shareCanvas");
|
await wxShare("shareCanvas");
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "海报生成失败,请稍后重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
sharing.value = false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void audioManager.warmCommon();
|
||||||
uni.setKeepScreenOn({
|
uni.setKeepScreenOn({
|
||||||
keepScreenOn: true,
|
keepScreenOn: true,
|
||||||
});
|
});
|
||||||
@@ -110,41 +205,59 @@ onMounted(() => {
|
|||||||
uni.$on("share-image", onClickShare);
|
uni.$on("share-image", onClickShare);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(async () => {
|
||||||
uni.setKeepScreenOn({
|
uni.setKeepScreenOn({
|
||||||
keepScreenOn: false,
|
keepScreenOn: false,
|
||||||
});
|
});
|
||||||
uni.$off("socket-inbox", onReceiveMessage);
|
uni.$off("socket-inbox", onReceiveMessage);
|
||||||
uni.$off("share-image", onClickShare);
|
uni.$off("share-image", onClickShare);
|
||||||
|
await closeCalibrationLaser();
|
||||||
audioManager.stopAll();
|
audioManager.stopAll();
|
||||||
endPractiseAPI();
|
closeMatchWebSocket({ reason: "practice-leave" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (step.value === 0) {
|
if (step.value === 0) {
|
||||||
step.value = 1;
|
step.value = 1;
|
||||||
title.value = "-凹造型";
|
title.value = "-箭前准备";
|
||||||
} else if (step.value === 1) {
|
} 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;
|
showGuide.value = false;
|
||||||
btnDisabled.value = true;
|
btnDisabled.value = true;
|
||||||
step.value = 2;
|
|
||||||
title.value = "-感知距离";
|
|
||||||
const result = await createPractiseAPI(total, 120);
|
|
||||||
if (result) practiseId.value = result.id;
|
|
||||||
} else if (step.value === 2) {
|
|
||||||
showGuide.value = false;
|
|
||||||
step.value = 3;
|
step.value = 3;
|
||||||
title.value = "-小试牛刀";
|
title.value = "-感知距离";
|
||||||
|
const result = await createPractise(total);
|
||||||
|
if (!result) {
|
||||||
|
btnDisabled.value = false;
|
||||||
|
step.value = 2;
|
||||||
|
return;
|
||||||
|
}
|
||||||
} else if (step.value === 3) {
|
} else if (step.value === 3) {
|
||||||
title.value = "小试牛刀";
|
showGuide.value = false;
|
||||||
await startPractiseAPI();
|
|
||||||
scores.value = [];
|
|
||||||
step.value = 4;
|
step.value = 4;
|
||||||
|
title.value = "-小试牛刀";
|
||||||
|
} else if (step.value === 4) {
|
||||||
|
title.value = "小试牛刀";
|
||||||
|
const result = await beginPractise();
|
||||||
|
if (!result) return;
|
||||||
|
scores.value = [];
|
||||||
|
isSvip.value = false;
|
||||||
|
step.value = 5;
|
||||||
start.value = true;
|
start.value = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.$emit("play-sound", "请开始射击");
|
uni.$emit("play-sound", "请开始射击");
|
||||||
}, 300);
|
}, 300);
|
||||||
} else if (step.value === 5) {
|
} else if (step.value === 6) {
|
||||||
uni.navigateBack({
|
uni.navigateBack({
|
||||||
delta: 1,
|
delta: 1,
|
||||||
});
|
});
|
||||||
@@ -159,28 +272,35 @@ const onClose = async () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
practiseResult.value = {};
|
practiseResult.value = {};
|
||||||
showGuide.value = false;
|
showGuide.value = false;
|
||||||
step.value = 5;
|
step.value = 6;
|
||||||
}, 500);
|
}, 500);
|
||||||
} else {
|
} else {
|
||||||
practiseResult.value = {};
|
practiseResult.value = {};
|
||||||
start.value = false;
|
start.value = false;
|
||||||
scores.value = [];
|
scores.value = [];
|
||||||
step.value = 3;
|
isSvip.value = false;
|
||||||
const result = await createPractiseAPI(total, 120);
|
step.value = 4;
|
||||||
if (result) practiseId.value = result.id;
|
await createPractise(total);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getResultTipSrc = (result = {}) => {
|
||||||
|
const validCount = (result.details || []).filter(
|
||||||
|
(arrow) => arrow.x !== -30 && arrow.y !== -30
|
||||||
|
).length;
|
||||||
|
return `${RESULT_TIP_CDN}/${validCount < total ? "un" : ""}finish-tip.png`;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container :bgType="1" :title="title" :showBottom="step !== 4">
|
<Container :bgType="1" :title="title" :showBottom="step !== 5">
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<Guide
|
<Guide
|
||||||
v-if="step !== 4"
|
v-if="step !== 5"
|
||||||
:type="
|
:type="
|
||||||
step === 2
|
step === 3
|
||||||
? 2
|
? 2
|
||||||
: step === 5 || (step === 0 && user.nickName.length > 6)
|
: step === 6 || (step === 0 && user.nickName.length > 6)
|
||||||
? 1
|
? 1
|
||||||
: 0
|
: 0
|
||||||
"
|
"
|
||||||
@@ -196,25 +316,28 @@ const onClose = async () => {
|
|||||||
,这是新人必刷小任务,0基础小白也能快速掌握弓箭技巧和游戏规则哦~:)
|
,这是新人必刷小任务,0基础小白也能快速掌握弓箭技巧和游戏规则哦~:)
|
||||||
</text>
|
</text>
|
||||||
<text v-if="step === 1" :style="{ fontSize: '28rpx' }"
|
<text v-if="step === 1" :style="{ fontSize: '28rpx' }"
|
||||||
>这是我们人帅技高的高教练。首先,请按教练示范,尝试自己去做这些动作和手势吧。</text
|
>这位就是人帅技高的高教练!接下来请跟随教练指引,做好射箭前期准备。</text
|
||||||
|
>
|
||||||
|
<text v-if="step === 2" :style="{ fontSize: '28rpx' }"
|
||||||
|
>请按下方步骤完成智能弓校准,让瞄准器和靶子保持对齐。</text
|
||||||
>
|
>
|
||||||
<view
|
<view
|
||||||
class="guide-tips"
|
class="guide-tips"
|
||||||
:style="{ marginTop: '8rpx' }"
|
:style="{ marginTop: '8rpx' }"
|
||||||
v-if="step === 2"
|
v-if="step === 3"
|
||||||
>
|
>
|
||||||
<text>你知道5米射程有多远吗?</text>
|
<text>你知道5米射程有多远吗?</text>
|
||||||
<text>
|
<text>
|
||||||
在我们的排位赛中,射程小于5米的成绩无效、哦!建议平时练习距离至少5米。现在,来边射箭边调整你的站位点吧!
|
在我们的排位赛中,射程小于5米的成绩无效、哦!建议平时练习距离至少5米。现在,来边射箭边调整你的站位点吧!
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="guide-tips" v-if="step === 3">
|
<view class="guide-tips" v-if="step === 4">
|
||||||
<text>一切准备就绪</text>
|
<text>一切准备就绪</text>
|
||||||
<text :style="{ fontSize: '28rpx' }"
|
<text :style="{ fontSize: '28rpx' }"
|
||||||
>试着完成一个真正的弓箭手任务吧!</text
|
>试着完成一个真正的弓箭手任务吧!</text
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
<view class="guide-tips" v-if="step === 5">
|
<view class="guide-tips" v-if="step === 6">
|
||||||
<text>新手试炼场通关啦,优秀!</text>
|
<text>新手试炼场通关啦,优秀!</text>
|
||||||
<text :style="{ fontSize: '28rpx' }"
|
<text :style="{ fontSize: '28rpx' }"
|
||||||
>反曲弓运动基本知识和射灵世界系统规则你已Get,是不是挺容易呀:)</text
|
>反曲弓运动基本知识和射灵世界系统规则你已Get,是不是挺容易呀:)</text
|
||||||
@@ -231,35 +354,56 @@ const onClose = async () => {
|
|||||||
src="https://static.shelingxingqiu.com/attachment/2025-11-17/deas80ef1sf9td0leq.png"
|
src="https://static.shelingxingqiu.com/attachment/2025-11-17/deas80ef1sf9td0leq.png"
|
||||||
class="try-tip"
|
class="try-tip"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
v-if="step === 3"
|
v-if="step === 4"
|
||||||
/>
|
/>
|
||||||
<image
|
<image
|
||||||
src="https://static.shelingxingqiu.com/attachment/2025-07-01/db0ehpz9lav58g5drl.png"
|
src="https://static.shelingxingqiu.com/attachment/2025-07-01/db0ehpz9lav58g5drl.png"
|
||||||
class="try-tip"
|
class="try-tip"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
v-if="step === 5"
|
v-if="step === 6"
|
||||||
/>
|
/>
|
||||||
<view style="height: 570px" v-if="step === 1">
|
<view style="height: 570px" v-if="step === 1">
|
||||||
<Swiper :onChange="onSwiperIndexChange" :data="guideImages" />
|
<Swiper
|
||||||
|
:current="guideSwiperIndex"
|
||||||
|
:onChange="onSwiperIndexChange"
|
||||||
|
:data="guideImages"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
<ShootProgress v-if="step === 4" tips="请开始连续射箭" :start="start" />
|
<view class="calibration-container" v-if="step === 2">
|
||||||
<TestDistance v-if="step === 2" :guide="false" />
|
<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" :targetType="40" />
|
||||||
<view
|
<view
|
||||||
class="user-row"
|
class="user-row"
|
||||||
v-if="step === 4"
|
v-if="step === 5"
|
||||||
:style="{ marginBottom: step === 2 ? '40px' : '0' }"
|
:style="{ marginBottom: '0' }"
|
||||||
>
|
>
|
||||||
<Avatar :src="user.avatar" :size="35" />
|
<Avatar :src="user.avatar" :size="35" />
|
||||||
<BowPower />
|
<BowPower />
|
||||||
</view>
|
</view>
|
||||||
<BowTarget
|
<BowTarget
|
||||||
v-if="step === 4"
|
v-if="step === 5"
|
||||||
:currentRound="step === 4 ? scores.length : 0"
|
:currentRound="step === 5 ? scores.length : 0"
|
||||||
:totalRound="step === 4 ? total : 0"
|
:totalRound="step === 5 ? total : 0"
|
||||||
:scores="scores"
|
:scores="scores"
|
||||||
|
:isSvip="isSvip"
|
||||||
|
:targetType="40"
|
||||||
|
stable-shot-effect
|
||||||
/>
|
/>
|
||||||
<ScorePanel
|
<ScorePanel
|
||||||
v-if="step === 4"
|
v-if="step === 5"
|
||||||
:total="total"
|
:total="total"
|
||||||
:rowCount="6"
|
:rowCount="6"
|
||||||
:arrows="scores"
|
:arrows="scores"
|
||||||
@@ -270,13 +414,7 @@ const onClose = async () => {
|
|||||||
:total="total"
|
:total="total"
|
||||||
:onClose="onClose"
|
:onClose="onClose"
|
||||||
:result="practiseResult"
|
:result="practiseResult"
|
||||||
:tipSrc="`../static/${
|
:tipSrc="getResultTipSrc(practiseResult)"
|
||||||
practiseResult.details.filter(
|
|
||||||
(arrow) => arrow.x !== -30 && arrow.y !== -30
|
|
||||||
).length < total
|
|
||||||
? 'un'
|
|
||||||
: ''
|
|
||||||
}finish-tip.png`"
|
|
||||||
/>
|
/>
|
||||||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||||||
</view>
|
</view>
|
||||||
@@ -287,7 +425,7 @@ const onClose = async () => {
|
|||||||
step === 1 ? "学会了,我摆得比教练还帅" : "我找到合适的点位了"
|
step === 1 ? "学会了,我摆得比教练还帅" : "我找到合适的点位了"
|
||||||
}}</text>
|
}}</text>
|
||||||
</BubbleTip>
|
</BubbleTip>
|
||||||
{{ stepButtonTexts[step] }}
|
{{ currentStepButtonText }}
|
||||||
</SButton>
|
</SButton>
|
||||||
</template>
|
</template>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -301,4 +439,43 @@ const onClose = async () => {
|
|||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
margin: 0 10px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ const data = ref({});
|
|||||||
const showOverlay = ref(false);
|
const showOverlay = ref(false);
|
||||||
/** 自动关闭覆盖层的计时器 */
|
/** 自动关闭覆盖层的计时器 */
|
||||||
const overlayTimer = ref(null);
|
const overlayTimer = ref(null);
|
||||||
|
const overlayDelayTimer = ref(null);
|
||||||
|
let loadGeneration = 0;
|
||||||
/** 控制经验条是否执行进场动画(弹窗关闭后才置 true,避免动画被遮挡) */
|
/** 控制经验条是否执行进场动画(弹窗关闭后才置 true,避免动画被遮挡) */
|
||||||
const showExpAnim = ref(false);
|
const showExpAnim = ref(false);
|
||||||
|
|
||||||
@@ -83,6 +85,13 @@ const mvpPlayer = computed(() => {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mvpTotalRing = computed(
|
||||||
|
() =>
|
||||||
|
mvpPlayer.value?.playerMatchResult?.totalRing ??
|
||||||
|
mvpPlayer.value?.totalRing ??
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MVP 玩家所在队伍编号(1=蓝队,2=红队)
|
* MVP 玩家所在队伍编号(1=蓝队,2=红队)
|
||||||
* 通过比对 mvpPlayer.id 与 blueTeamPlayers 确定,用于选择背景图(mvp-blue / mvp-red)
|
* 通过比对 mvpPlayer.id 与 blueTeamPlayers 确定,用于选择背景图(mvp-blue / mvp-red)
|
||||||
@@ -92,6 +101,14 @@ const mvpTeam = computed(() => {
|
|||||||
return blueTeamPlayers.value.some((p) => p.id === mvpPlayer.value.id) ? 1 : 2;
|
return blueTeamPlayers.value.some((p) => p.id === mvpPlayer.value.id) ? 1 : 2;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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" : "",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 激励语图片 URL(在 onLoad 中确定 ifWin 后赋值,避免 Math.random 放在 computed 里产生缓存不一致问题)
|
* 激励语图片 URL(在 onLoad 中确定 ifWin 后赋值,避免 Math.random 放在 computed 里产生缓存不一致问题)
|
||||||
*/
|
*/
|
||||||
@@ -128,6 +145,8 @@ const meleeRankList = computed(() => {
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
avatar: p.avatar || "",
|
avatar: p.avatar || "",
|
||||||
name: p.name || "",
|
name: p.name || "",
|
||||||
|
vip: p.vip,
|
||||||
|
sVip: p.sVip,
|
||||||
// rank_lvl 字段可能缺失,缺失时显示空字符串,避免 getLvlName(undefined) 返回错误段位名
|
// rank_lvl 字段可能缺失,缺失时显示空字符串,避免 getLvlName(undefined) 返回错误段位名
|
||||||
lvlName: p.rank_lvl != null ? getLvlName(p.rank_lvl) : "",
|
lvlName: p.rank_lvl != null ? getLvlName(p.rank_lvl) : "",
|
||||||
totalRing: resultItem.totalRing ?? 0,
|
totalRing: resultItem.totalRing ?? 0,
|
||||||
@@ -165,9 +184,11 @@ function getMeleeAvatarBorderColor(rank) {
|
|||||||
// ---- 生命周期 ----
|
// ---- 生命周期 ----
|
||||||
|
|
||||||
onLoad(async (options) => {
|
onLoad(async (options) => {
|
||||||
|
const currentLoadId = ++loadGeneration;
|
||||||
if (!options.battleId) return;
|
if (!options.battleId) return;
|
||||||
|
|
||||||
const result = await getBattleAPI(options.battleId);
|
const result = await getBattleAPI(options.battleId);
|
||||||
|
if (currentLoadId !== loadGeneration) return;
|
||||||
data.value = result;
|
data.value = result;
|
||||||
|
|
||||||
// 从 teams 各队伍的 players 中找到当前用户,解析经验进度条所需字段
|
// 从 teams 各队伍的 players 中找到当前用户,解析经验进度条所需字段
|
||||||
@@ -209,7 +230,8 @@ onLoad(async (options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 数据加载完成后延迟 1 秒再显示激励弹窗,避免进场时画面太杂
|
// 数据加载完成后延迟 1 秒再显示激励弹窗,避免进场时画面太杂
|
||||||
setTimeout(() => {
|
overlayDelayTimer.value = setTimeout(() => {
|
||||||
|
overlayDelayTimer.value = null;
|
||||||
showOverlay.value = true;
|
showOverlay.value = true;
|
||||||
// 弹窗显示后 2.5 秒自动关闭
|
// 弹窗显示后 2.5 秒自动关闭
|
||||||
overlayTimer.value = setTimeout(() => {
|
overlayTimer.value = setTimeout(() => {
|
||||||
@@ -219,7 +241,15 @@ onLoad(async (options) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (overlayTimer.value) clearTimeout(overlayTimer.value);
|
loadGeneration += 1;
|
||||||
|
if (overlayDelayTimer.value) {
|
||||||
|
clearTimeout(overlayDelayTimer.value);
|
||||||
|
overlayDelayTimer.value = null;
|
||||||
|
}
|
||||||
|
if (overlayTimer.value) {
|
||||||
|
clearTimeout(overlayTimer.value);
|
||||||
|
overlayTimer.value = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- 方法 ----
|
// ---- 方法 ----
|
||||||
@@ -305,7 +335,7 @@ function goBack() {
|
|||||||
<Container
|
<Container
|
||||||
:bgType="data.mode > 3 ? -1 : 0"
|
:bgType="data.mode > 3 ? -1 : 0"
|
||||||
bgColor="#000000"
|
bgColor="#000000"
|
||||||
:onBack="goBack"
|
:onBack="exit"
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- ----- Banner 区:game 胜负展示图(仅 NvN 对抗模式)----- -->
|
<!-- ----- Banner 区:game 胜负展示图(仅 NvN 对抗模式)----- -->
|
||||||
@@ -332,7 +362,20 @@ function goBack() {
|
|||||||
<view class="team-players team-players-blue">
|
<view class="team-players team-players-blue">
|
||||||
<view v-for="p in blueTeamPlayers" :key="p.id" class="player-item">
|
<view v-for="p in blueTeamPlayers" :key="p.id" class="player-item">
|
||||||
<Avatar :src="p.avatar" :size="34" borderColor="#8FB4FD" />
|
<Avatar :src="p.avatar" :size="34" borderColor="#8FB4FD" />
|
||||||
<text class="player-name player-name-blue">{{ p.name }}</text>
|
<view
|
||||||
|
v-if="isMember(p)"
|
||||||
|
:class="[
|
||||||
|
'player-name',
|
||||||
|
'player-name-blue',
|
||||||
|
...getMemberNicknameClass(p),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<text class="member-nickname__text">{{ p.name }}</text>
|
||||||
|
<text v-if="p.sVip === true" class="member-nickname__shine">
|
||||||
|
{{ p.name }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-else class="player-name player-name-blue">{{ p.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -340,7 +383,20 @@ function goBack() {
|
|||||||
<view class="team-players team-players-red">
|
<view class="team-players team-players-red">
|
||||||
<view v-for="p in redTeamPlayers" :key="p.id" class="player-item">
|
<view v-for="p in redTeamPlayers" :key="p.id" class="player-item">
|
||||||
<Avatar :src="p.avatar" :size="34" borderColor="#E67470" />
|
<Avatar :src="p.avatar" :size="34" borderColor="#E67470" />
|
||||||
<text class="player-name player-name-red">{{ p.name }}</text>
|
<view
|
||||||
|
v-if="isMember(p)"
|
||||||
|
:class="[
|
||||||
|
'player-name',
|
||||||
|
'player-name-red',
|
||||||
|
...getMemberNicknameClass(p),
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<text class="member-nickname__text">{{ p.name }}</text>
|
||||||
|
<text v-if="p.sVip === true" class="member-nickname__shine">
|
||||||
|
{{ p.name }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-else class="player-name player-name-red">{{ p.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -368,14 +424,14 @@ function goBack() {
|
|||||||
<!-- 背景图:蓝队用 mvp-blue.png,红队用 mvp-red.png(平行四边形渐变切图) -->
|
<!-- 背景图:蓝队用 mvp-blue.png,红队用 mvp-red.png(平行四边形渐变切图) -->
|
||||||
<image
|
<image
|
||||||
class="mvp-bg"
|
class="mvp-bg"
|
||||||
:src="mvpTeam === 1 ? '../static/mvp-blue.png' : '../static/mvp-red.png'"
|
:src="mvpTeam === 1 ? 'https://static.shelingxingqiu.com/shootmini/static/mvp-blue.png' : 'https://static.shelingxingqiu.com/shootmini/static/mvp-red.png'"
|
||||||
mode="scaleToFill"
|
mode="scaleToFill"
|
||||||
/>
|
/>
|
||||||
<!-- 左:MVP 标题图 + 斩获环数 -->
|
<!-- 左:MVP 标题图 + 斩获环数 -->
|
||||||
<view class="mvp-info">
|
<view class="mvp-info">
|
||||||
<image class="mvp-badge" src="../static/mvp-tip.png" mode="widthFix" />
|
<image class="mvp-badge" src="https://static.shelingxingqiu.com/shootmini/static/mvp-tip.png" mode="widthFix" />
|
||||||
<view class="mvp-rings">
|
<view class="mvp-rings">
|
||||||
斩获<text class="mvp-rings-num">{{ mvpPlayer.totalRing }}</text>环
|
斩获<text class="mvp-rings-num">{{ mvpTotalRing }}</text>环
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<!-- 右:MVP 头像 + 名字,边框颜色跟随 MVP 所在队伍 -->
|
<!-- 右:MVP 头像 + 名字,边框颜色跟随 MVP 所在队伍 -->
|
||||||
@@ -385,7 +441,16 @@ function goBack() {
|
|||||||
:size="53"
|
:size="53"
|
||||||
:borderColor="mvpTeam === 1 ? '#5FADFF' : '#FF6060'"
|
:borderColor="mvpTeam === 1 ? '#5FADFF' : '#FF6060'"
|
||||||
/>
|
/>
|
||||||
<text class="mvp-name">{{ mvpPlayer.name }}</text>
|
<view
|
||||||
|
v-if="isMember(mvpPlayer)"
|
||||||
|
:class="['mvp-name', ...getMemberNicknameClass(mvpPlayer)]"
|
||||||
|
>
|
||||||
|
<text class="member-nickname__text">{{ mvpPlayer.name }}</text>
|
||||||
|
<text v-if="mvpPlayer.sVip === true" class="member-nickname__shine">
|
||||||
|
{{ mvpPlayer.name }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<text v-else class="mvp-name">{{ mvpPlayer.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -394,7 +459,7 @@ function goBack() {
|
|||||||
<!-- 本场战绩标题:装饰横线 + 标题图 + 装饰横线 -->
|
<!-- 本场战绩标题:装饰横线 + 标题图 + 装饰横线 -->
|
||||||
<view class="melee-title">
|
<view class="melee-title">
|
||||||
<view class="melee-title-line" />
|
<view class="melee-title-line" />
|
||||||
<image class="melee-title-img" src="../static/battle-result.png" mode="scaleToFill" />
|
<image class="melee-title-img" src="https://static.shelingxingqiu.com/shootmini/static/battle-result.png" mode="scaleToFill" />
|
||||||
<view class="melee-title-line" />
|
<view class="melee-title-line" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -433,7 +498,16 @@ function goBack() {
|
|||||||
|
|
||||||
<!-- 昵称 + 段位 -->
|
<!-- 昵称 + 段位 -->
|
||||||
<view class="rank-player-info">
|
<view class="rank-player-info">
|
||||||
<text class="rank-player-name">{{ item.name }}</text>
|
<view
|
||||||
|
v-if="isMember(item)"
|
||||||
|
:class="['rank-player-name', ...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-player-name">{{ item.name }}</text>
|
||||||
<text class="rank-player-lvl">{{ item.lvlName }}</text>
|
<text class="rank-player-lvl">{{ item.lvlName }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -613,6 +687,12 @@ function goBack() {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-name .member-nickname__text,
|
||||||
|
.player-name .member-nickname__shine {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 得分行 ---- */
|
/* ---- 得分行 ---- */
|
||||||
.vs-scores-row {
|
.vs-scores-row {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -752,6 +832,11 @@ function goBack() {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mvp-name .member-nickname__text,
|
||||||
|
.mvp-name .member-nickname__shine {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================
|
/* ============================
|
||||||
查看完整成绩链接
|
查看完整成绩链接
|
||||||
============================ */
|
============================ */
|
||||||
@@ -1072,6 +1157,12 @@ function goBack() {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rank-player-name .member-nickname__text,
|
||||||
|
.rank-player-name .member-nickname__shine {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.rank-player-lvl {
|
.rank-player-lvl {
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
|||||||
+105
-15
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import GuideTwo from "@/components/GuideTwo.vue";
|
import GuideTwo from "@/components/GuideTwo.vue";
|
||||||
@@ -8,14 +8,16 @@ import SModal from "@/components/SModal.vue";
|
|||||||
import Signin from "@/components/Signin.vue";
|
import Signin from "@/components/Signin.vue";
|
||||||
import CreateRoom from "@/components/CreateRoom.vue";
|
import CreateRoom from "@/components/CreateRoom.vue";
|
||||||
import Avatar from "@/components/Avatar.vue";
|
import Avatar from "@/components/Avatar.vue";
|
||||||
|
import ModalDialog from "@/components/ModalDialog.vue";
|
||||||
|
|
||||||
import { getRoomAPI, joinRoomAPI, getBattleDataAPI } from "@/apis";
|
import { getRoomAPI, joinRoomAPI, getBattleDataAPI, getDailyCountAPI } from "@/apis";
|
||||||
import { debounce, canEenter } from "@/util";
|
import { debounce, canEenter, getLimitCountText, isLimitReached } from "@/util";
|
||||||
|
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user, device, online, game } = storeToRefs(store);
|
const { user, device, online, game, dailyCount } = storeToRefs(store);
|
||||||
|
const { updateDailyCount } = store;
|
||||||
|
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
const showSignin = ref(false);
|
const showSignin = ref(false);
|
||||||
@@ -24,6 +26,13 @@ const roomNumber = ref("");
|
|||||||
const data = ref({});
|
const data = ref({});
|
||||||
const roomID = ref("");
|
const roomID = ref("");
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
const showLimitModal = ref(false);
|
||||||
|
const createRoomLoading = ref(false);
|
||||||
|
const isSVip = computed(() => user.value.sVip === true);
|
||||||
|
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||||
|
const challengeLimitText = computed(() =>
|
||||||
|
getLimitCountText("约战", dailyCount.value.challenge)
|
||||||
|
);
|
||||||
|
|
||||||
const enterRoom = debounce(async (number) => {
|
const enterRoom = debounce(async (number) => {
|
||||||
if (loading.value) return;
|
if (loading.value) return;
|
||||||
@@ -37,6 +46,8 @@ const enterRoom = debounce(async (number) => {
|
|||||||
showModal.value = true;
|
showModal.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
loading.value = true;
|
||||||
|
let keepLoading = false;
|
||||||
try {
|
try {
|
||||||
const room = await getRoomAPI(number);
|
const room = await getRoomAPI(number);
|
||||||
if (!room.number) {
|
if (!room.number) {
|
||||||
@@ -55,18 +66,55 @@ const enterRoom = debounce(async (number) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loading.value = true;
|
keepLoading = true;
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: "/pages/battle-room?roomNumber=" + number,
|
url: "/pages/battle-room?roomNumber=" + number,
|
||||||
|
fail: () => {
|
||||||
|
loading.value = false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (!keepLoading) loading.value = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const onCreateRoom = async () => {
|
const onCreateRoom = debounce(async () => {
|
||||||
|
if (createRoomLoading.value) return;
|
||||||
if (!canEenter(user.value, device.value, online.value)) return;
|
if (!canEenter(user.value, device.value, online.value)) return;
|
||||||
|
createRoomLoading.value = true;
|
||||||
|
try {
|
||||||
|
const countData = await loadDailyCount();
|
||||||
|
if (isLimitReached(countData.challenge)) {
|
||||||
|
showLimitModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
warnning.value = "";
|
warnning.value = "";
|
||||||
showModal.value = true;
|
showModal.value = true;
|
||||||
|
} finally {
|
||||||
|
createRoomLoading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeLimitModal = () => {
|
||||||
|
showLimitModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goVipPage = () => {
|
||||||
|
showLimitModal.value = false;
|
||||||
|
uni.navigateTo({
|
||||||
|
url: "/pages/member/be-vip",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadDailyCount = async () => {
|
||||||
|
if (!user.value.id) return dailyCount.value;
|
||||||
|
try {
|
||||||
|
const result = await getDailyCountAPI();
|
||||||
|
updateDailyCount(result);
|
||||||
|
return result || dailyCount.value;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("load daily count error", error);
|
||||||
|
return dailyCount.value;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const onSignin = () => {
|
const onSignin = () => {
|
||||||
if (roomID.value && user.value.id) enterRoom(roomID.value);
|
if (roomID.value && user.value.id) enterRoom(roomID.value);
|
||||||
@@ -80,8 +128,12 @@ const goMyRecord = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
|
loading.value = false;
|
||||||
if (user.value.id) {
|
if (user.value.id) {
|
||||||
const result = await getBattleDataAPI();
|
const [result] = await Promise.all([
|
||||||
|
getBattleDataAPI(),
|
||||||
|
loadDailyCount(),
|
||||||
|
]);
|
||||||
data.value = result;
|
data.value = result;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -106,7 +158,18 @@ onLoad(async (options) => {
|
|||||||
<view class="my-data">
|
<view class="my-data">
|
||||||
<view>
|
<view>
|
||||||
<Avatar :rankLvl="user.rankLvl" :src="user.avatar" :size="30" />
|
<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>
|
<text class="my-record-btn" @click="goMyRecord">我的战绩</text>
|
||||||
</view>
|
</view>
|
||||||
<view>
|
<view>
|
||||||
@@ -146,16 +209,24 @@ onLoad(async (options) => {
|
|||||||
</view>
|
</view>
|
||||||
<view class="create-room">
|
<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" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/room-notfound-title.png" mode="widthFix" />
|
||||||
<view>
|
<view>
|
||||||
<image :src="user.avatar" mode="widthFix" />
|
<image :src="user.avatar" mode="widthFix" />
|
||||||
<image src="../static/versus.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/versus.png" mode="widthFix" />
|
||||||
<view>
|
<view>
|
||||||
<image src="../static/question-mark.png" mode="widthFix" />
|
<image src="../static/question-mark.png" mode="widthFix" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view>
|
<view>
|
||||||
<SButton width="80%" :rounded="30" :onClick="() => $clickSound(onCreateRoom)">
|
<view v-if="challengeLimitText" class="pp-text">
|
||||||
|
{{ challengeLimitText }}
|
||||||
|
</view>
|
||||||
|
<SButton
|
||||||
|
width="80%"
|
||||||
|
:rounded="30"
|
||||||
|
:disabled="createRoomLoading"
|
||||||
|
:onClick="() => { $clickSound(); return onCreateRoom(); }"
|
||||||
|
>
|
||||||
创建约战房
|
创建约战房
|
||||||
</SButton>
|
</SButton>
|
||||||
</view>
|
</view>
|
||||||
@@ -170,6 +241,14 @@ onLoad(async (options) => {
|
|||||||
<Signin :show="showSignin" :onClose="onSignin" />
|
<Signin :show="showSignin" :onClose="onSignin" />
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
|
<ModalDialog
|
||||||
|
:show="showLimitModal"
|
||||||
|
:content="'今日约战次数已经用完\n开通会员可增加次数'"
|
||||||
|
cancelText="知道了"
|
||||||
|
confirmText="去开通"
|
||||||
|
:onCancel="closeLimitModal"
|
||||||
|
:onConfirm="goVipPage"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -255,7 +334,7 @@ onLoad(async (options) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.create-room>view:nth-child(3) {
|
.create-room>view:nth-child(3) {
|
||||||
margin: 12vw auto;
|
margin: 12vw auto 5vw auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -291,6 +370,13 @@ onLoad(async (options) => {
|
|||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pp-text{
|
||||||
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 22rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.warnning {
|
.warnning {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -335,13 +421,17 @@ onLoad(async (options) => {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.my-data>view:first-child>text {
|
.my-data>view:first-child>.member-nickname {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 17px;
|
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.my-data>view:first-child>.member-nickname__text,
|
||||||
|
.my-data>view:first-child>.member-nickname__shine {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
.my-data>view:last-child {
|
.my-data>view:last-child {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|||||||
+115
-28
@@ -1,31 +1,66 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user } = storeToRefs(store);
|
const { user } = storeToRefs(store);
|
||||||
|
|
||||||
|
const MAX_LEVEL = 100;
|
||||||
|
const LEVEL_NODE_WIDTH_RPX = 72;
|
||||||
|
const TRACK_PADDING_RPX = 20;
|
||||||
|
const windowWidth = uni.getSystemInfoSync().windowWidth;
|
||||||
|
const rpxToPx = (value) => (value * windowWidth) / 750;
|
||||||
|
const levels = Array.from({ length: MAX_LEVEL }, (_, index) => index + 1);
|
||||||
|
const currentLevel = computed(() => {
|
||||||
|
const level = Number(user.value?.lvl) || 1;
|
||||||
|
return Math.min(Math.max(level, 1), MAX_LEVEL);
|
||||||
|
});
|
||||||
|
const currentLevelScrollLeft = computed(() => {
|
||||||
|
const nodeWidth = rpxToPx(LEVEL_NODE_WIDTH_RPX);
|
||||||
|
const trackPadding = rpxToPx(TRACK_PADDING_RPX);
|
||||||
|
const contentWidth = rpxToPx(
|
||||||
|
TRACK_PADDING_RPX * 2 + MAX_LEVEL * LEVEL_NODE_WIDTH_RPX
|
||||||
|
);
|
||||||
|
const currentCenter =
|
||||||
|
trackPadding + (currentLevel.value - 1) * nodeWidth + nodeWidth / 2;
|
||||||
|
const targetLeft = currentCenter - windowWidth / 2;
|
||||||
|
const maxScrollLeft = Math.max(contentWidth - windowWidth, 0);
|
||||||
|
|
||||||
|
return Math.min(Math.max(targetLeft, 0), maxScrollLeft);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container title="等级介绍">
|
<Container title="等级介绍">
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<!-- 等级进度条 -->
|
<!-- 等级进度条 -->
|
||||||
<view class="level-progress">
|
<scroll-view
|
||||||
<view v-for="(_, index) in 10" :key="index" class="progress-dot">
|
class="level-progress"
|
||||||
|
scroll-x
|
||||||
|
:show-scrollbar="false"
|
||||||
|
:scroll-left="currentLevelScrollLeft"
|
||||||
|
scroll-with-animation
|
||||||
|
>
|
||||||
|
<view class="level-track">
|
||||||
<view
|
<view
|
||||||
:style="{
|
v-for="level in levels"
|
||||||
backgroundColor:
|
:id="`level-${level}`"
|
||||||
index + 1 < user.lvl
|
:key="level"
|
||||||
? '#fff9'
|
:class="[
|
||||||
: index + 1 === user.lvl
|
'level-node',
|
||||||
? '#fed847'
|
level < currentLevel ? 'level-node--done' : '',
|
||||||
: 'transparent',
|
level === currentLevel ? 'level-node--current' : '',
|
||||||
borderColor: index + 1 === user.lvl ? '#fed847' : '#fff9',
|
]"
|
||||||
}"
|
>
|
||||||
/>
|
<view class="level-node__top">
|
||||||
<view />
|
<view class="level-node__dot" />
|
||||||
|
<view v-if="level < MAX_LEVEL" class="level-node__line" />
|
||||||
|
</view>
|
||||||
|
<text class="level-node__label">{{ level }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
|
||||||
<!-- 说明文本 -->
|
<!-- 说明文本 -->
|
||||||
<view class="body">
|
<view class="body">
|
||||||
@@ -65,28 +100,80 @@ const { user } = storeToRefs(store);
|
|||||||
|
|
||||||
.level-progress {
|
.level-progress {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 32rpx;
|
white-space: nowrap;
|
||||||
display: flex;
|
padding: 24rpx 0 34rpx;
|
||||||
justify-content: center;
|
box-sizing: border-box;
|
||||||
padding-top: 20rpx;
|
|
||||||
padding-bottom: 40rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-dot {
|
.level-track {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-left: 20rpx;
|
||||||
|
padding-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 72rpx;
|
||||||
|
flex: 0 0 72rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node__top {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.progress-dot > view:first-child {
|
|
||||||
width: 3.8vw;
|
.level-node__dot {
|
||||||
height: 3.8vw;
|
width: 28rpx;
|
||||||
|
height: 28rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid #fff9;
|
border: 3rpx solid rgba(255, 255, 255, 0.45);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.progress-dot > view:last-child {
|
|
||||||
width: 3.8vw;
|
.level-node__line {
|
||||||
height: 1px;
|
flex: 1;
|
||||||
margin: 0 2px;
|
height: 2rpx;
|
||||||
background-color: #fff9;
|
background-color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node__label {
|
||||||
|
width: 28rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 28rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node--done .level-node__dot {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node--done .level-node__line {
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node--done .level-node__label {
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node--current .level-node__dot {
|
||||||
|
background-color: #fed847;
|
||||||
|
border-color: #fed847;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node--current .level-node__line {
|
||||||
|
background-color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-node--current .level-node__label {
|
||||||
|
color: #fed847;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
|
|||||||
+323
-16
@@ -1,19 +1,23 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {onMounted, ref} from "vue";
|
import {onMounted, onUnmounted, ref, watch} from "vue";
|
||||||
import {onShareAppMessage, onShareTimeline, onShow} from "@dcloudio/uni-app";
|
import {onShareAppMessage, onShareTimeline, onShow} from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import AppFooter from "@/components/AppFooter.vue";
|
import AppFooter from "@/components/AppFooter.vue";
|
||||||
import UserHeader from "@/components/UserHeader.vue";
|
import UserHeader from "@/components/UserHeader.vue";
|
||||||
import Signin from "@/components/Signin.vue";
|
import Signin from "@/components/Signin.vue";
|
||||||
import BubbleTip from "@/components/BubbleTip.vue";
|
import BubbleTip from "@/components/BubbleTip.vue";
|
||||||
|
import OtaModal from "@/components/OtaModal.vue";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
checkUserBindAPI,
|
checkUserBindAPI,
|
||||||
getAppConfig,
|
getAppConfig,
|
||||||
getDeviceBatteryAPI,
|
getDeviceBatteryAPI,
|
||||||
|
getHardwareBoxTaskStatusAPI,
|
||||||
|
getHardwareBoxVersionAPI,
|
||||||
getHomeData,
|
getHomeData,
|
||||||
getMyDevicesAPI,
|
getMyDevicesAPI,
|
||||||
getScoreRankList,
|
getScoreRankList,
|
||||||
|
sendHardwareBoxUpdateAPI,
|
||||||
silentLoginAPI,
|
silentLoginAPI,
|
||||||
} from "@/apis";
|
} from "@/apis";
|
||||||
import {topThreeColors} from "@/constants";
|
import {topThreeColors} from "@/constants";
|
||||||
@@ -26,6 +30,7 @@ const {
|
|||||||
updateConfig,
|
updateConfig,
|
||||||
updateUser,
|
updateUser,
|
||||||
updateDevice,
|
updateDevice,
|
||||||
|
clearDevice,
|
||||||
getLvlName,
|
getLvlName,
|
||||||
getLvlNameByScore,
|
getLvlNameByScore,
|
||||||
updateOnline,
|
updateOnline,
|
||||||
@@ -36,6 +41,279 @@ const showModal = ref(false);
|
|||||||
const showGuide = ref(false);
|
const showGuide = ref(false);
|
||||||
const scoreRankList = ref([]);
|
const scoreRankList = ref([]);
|
||||||
|
|
||||||
|
// OTA 相关
|
||||||
|
const otaVisible = ref(false);
|
||||||
|
const otaState = ref("new_version");
|
||||||
|
const otaProgress = ref(0);
|
||||||
|
const otaInfo = ref({
|
||||||
|
versionNumber: "",
|
||||||
|
versionInfo: "",
|
||||||
|
resourceUrl: "",
|
||||||
|
forceUpdate: false,
|
||||||
|
});
|
||||||
|
const isStartingOta = ref(false);
|
||||||
|
let isCheckingOta = false;
|
||||||
|
let otaCheckQueuedForOnline = false;
|
||||||
|
let otaProgressTimer = null;
|
||||||
|
let otaStatusTimer = null;
|
||||||
|
let otaTimeoutTimer = null;
|
||||||
|
let otaResultTimer = null;
|
||||||
|
let otaStatusPollCount = 0;
|
||||||
|
let otaUpdateRunId = 0;
|
||||||
|
// 首页 OTA 轮询采用请求次数和总时长双重兜底,避免更新中状态长期卡住。
|
||||||
|
const OTA_TASK_STATUS_POLL_INTERVAL = 2000;
|
||||||
|
const OTA_TASK_STATUS_MAX_POLL_COUNT = 15;
|
||||||
|
const OTA_TASK_STATUS_TIMEOUT = 30000;
|
||||||
|
|
||||||
|
// 清理首页 OTA 更新定时器,避免弹窗关闭或页面卸载后继续轮询。
|
||||||
|
const clearOtaUpdateTimers = () => {
|
||||||
|
clearInterval(otaProgressTimer);
|
||||||
|
clearTimeout(otaStatusTimer);
|
||||||
|
clearTimeout(otaTimeoutTimer);
|
||||||
|
clearTimeout(otaResultTimer);
|
||||||
|
otaProgressTimer = null;
|
||||||
|
otaStatusTimer = null;
|
||||||
|
otaTimeoutTimer = null;
|
||||||
|
otaResultTimer = null;
|
||||||
|
otaStatusPollCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使当前 OTA 运行失效,确保旧请求返回后不会继续轮询或覆盖新状态。
|
||||||
|
const invalidateOtaUpdateRun = () => {
|
||||||
|
otaUpdateRunId += 1;
|
||||||
|
clearOtaUpdateTimers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOtaUpdateRunActive = (runId) =>
|
||||||
|
runId === otaUpdateRunId && otaState.value === "update_progress";
|
||||||
|
|
||||||
|
// 启动首页 OTA 本地进度动画,最终成功失败以后端任务状态为准。
|
||||||
|
const startOtaProgressAnimation = () => {
|
||||||
|
clearInterval(otaProgressTimer);
|
||||||
|
otaProgressTimer = setInterval(() => {
|
||||||
|
if (otaProgress.value >= 90) {
|
||||||
|
clearInterval(otaProgressTimer);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const increment = Math.max(0.5, 2 - otaProgress.value / 60);
|
||||||
|
otaProgress.value = Math.min(90, otaProgress.value + increment);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取并保存后端返回的 OTA 版本信息,供弹窗展示和更新接口使用。
|
||||||
|
const applyOtaVersionInfo = (versionInfo) => {
|
||||||
|
otaInfo.value = {
|
||||||
|
versionNumber: versionInfo?.versionNumber || "",
|
||||||
|
versionInfo: versionInfo?.versionInfo || "",
|
||||||
|
resourceUrl: versionInfo?.resourceUrl || "",
|
||||||
|
forceUpdate: Number(versionInfo?.forceUpdate) === 1,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查当前设备盒子是否存在可升级版本。
|
||||||
|
const checkOtaUpdate = async () => {
|
||||||
|
if (isCheckingOta || otaVisible.value) return;
|
||||||
|
isCheckingOta = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let deviceStatus;
|
||||||
|
try {
|
||||||
|
deviceStatus = await getDeviceBatteryAPI();
|
||||||
|
} catch (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deviceStatus?.online !== true || otaVisible.value) return;
|
||||||
|
|
||||||
|
let versionInfo;
|
||||||
|
try {
|
||||||
|
versionInfo = await getHardwareBoxVersionAPI();
|
||||||
|
} catch (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!versionInfo?.needUpdate || otaVisible.value) return;
|
||||||
|
applyOtaVersionInfo(versionInfo);
|
||||||
|
|
||||||
|
const dismissedAt = uni.getStorageSync("ota_dismissed_at");
|
||||||
|
const now = Date.now();
|
||||||
|
if (!otaInfo.value.forceUpdate && dismissedAt && now - dismissedAt < 24 * 60 * 60 * 1000) return;
|
||||||
|
otaState.value = "new_version";
|
||||||
|
otaVisible.value = true;
|
||||||
|
} finally {
|
||||||
|
isCheckingOta = false;
|
||||||
|
const shouldRecheckForOnline = otaCheckQueuedForOnline;
|
||||||
|
otaCheckQueuedForOnline = false;
|
||||||
|
if (shouldRecheckForOnline && !otaVisible.value) {
|
||||||
|
void checkOtaUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设备由 WS 通知上线时补查 OTA;已显示任何 OTA 弹窗时保留当前流程和结果。
|
||||||
|
watch(online, (nextOnline, previousOnline) => {
|
||||||
|
if (previousOnline !== false || nextOnline !== true || otaVisible.value) return;
|
||||||
|
if (isCheckingOta) {
|
||||||
|
otaCheckQueuedForOnline = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void checkOtaUpdate();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 拼接 OTA WiFi 页参数,让未连 WiFi 的设备继续使用同一份版本信息。
|
||||||
|
const getOtaWifiUrl = () => {
|
||||||
|
const { versionNumber, resourceUrl } = otaInfo.value;
|
||||||
|
const query = [
|
||||||
|
`versionNumber=${encodeURIComponent(versionNumber)}`,
|
||||||
|
`resourceUrl=${encodeURIComponent(resourceUrl)}`,
|
||||||
|
].join("&");
|
||||||
|
return `/pages/ota-wifi?${query}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 OTA 弹窗暂不更新,强制更新时不允许关闭。
|
||||||
|
const handleOtaDismiss = () => {
|
||||||
|
if (otaInfo.value.forceUpdate) return;
|
||||||
|
uni.setStorageSync("ota_dismissed_at", Date.now());
|
||||||
|
otaVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将首页 OTA 直连更新流程标记为失败。
|
||||||
|
const failHomeOtaUpdate = (runId) => {
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
invalidateOtaUpdateRun();
|
||||||
|
isStartingOta.value = false;
|
||||||
|
otaState.value = "update_failure";
|
||||||
|
otaVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 将首页 OTA 直连更新流程标记为成功。
|
||||||
|
const completeHomeOtaUpdate = (runId) => {
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
invalidateOtaUpdateRun();
|
||||||
|
isStartingOta.value = false;
|
||||||
|
otaProgress.value = 100;
|
||||||
|
const completedRunId = otaUpdateRunId;
|
||||||
|
otaResultTimer = setTimeout(() => {
|
||||||
|
otaResultTimer = null;
|
||||||
|
if (completedRunId !== otaUpdateRunId) return;
|
||||||
|
otaState.value = "update_success";
|
||||||
|
otaVisible.value = true;
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 轮询首页直接发起的 OTA 更新任务状态。
|
||||||
|
const pollHomeOtaTaskStatus = async (taskId, runId) => {
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
|
||||||
|
otaStatusPollCount += 1;
|
||||||
|
try {
|
||||||
|
const taskStatus = await getHardwareBoxTaskStatusAPI(taskId);
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
|
||||||
|
const status = Number(taskStatus?.status);
|
||||||
|
if (status === 2) {
|
||||||
|
completeHomeOtaUpdate(runId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === 3) {
|
||||||
|
failHomeOtaUpdate(runId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status === 0 || status === 1) {
|
||||||
|
if (otaStatusPollCount >= OTA_TASK_STATUS_MAX_POLL_COUNT) {
|
||||||
|
failHomeOtaUpdate(runId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
otaStatusTimer = setTimeout(() => {
|
||||||
|
otaStatusTimer = null;
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
void pollHomeOtaTaskStatus(taskId, runId);
|
||||||
|
}, OTA_TASK_STATUS_POLL_INTERVAL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failHomeOtaUpdate(runId);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
failHomeOtaUpdate(runId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设备盒子已连 WiFi 时,从首页直接传空 WiFi 信息发起 OTA 更新。
|
||||||
|
const startHomeOtaUpdate = async () => {
|
||||||
|
invalidateOtaUpdateRun();
|
||||||
|
const runId = otaUpdateRunId;
|
||||||
|
otaState.value = "update_progress";
|
||||||
|
otaVisible.value = true;
|
||||||
|
otaProgress.value = 0;
|
||||||
|
startOtaProgressAnimation();
|
||||||
|
otaTimeoutTimer = setTimeout(() => {
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
failHomeOtaUpdate(runId);
|
||||||
|
}, OTA_TASK_STATUS_TIMEOUT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updateResult = await sendHardwareBoxUpdateAPI({
|
||||||
|
versionNumber: otaInfo.value.versionNumber,
|
||||||
|
wifiSsid: "",
|
||||||
|
wifiPassword: "",
|
||||||
|
resourceUrl: otaInfo.value.resourceUrl,
|
||||||
|
});
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
if (!updateResult?.taskId) {
|
||||||
|
failHomeOtaUpdate(runId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void pollHomeOtaTaskStatus(updateResult.taskId, runId);
|
||||||
|
} catch (err) {
|
||||||
|
if (!isOtaUpdateRunActive(runId)) return;
|
||||||
|
failHomeOtaUpdate(runId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 点击立即更新时先判断设备是否在线并已通过 WiFi 联网,已联网则首页直接更新,否则跳转 WiFi 页面。
|
||||||
|
const handleOtaUpdate = async () => {
|
||||||
|
if (isStartingOta.value) return;
|
||||||
|
isStartingOta.value = true;
|
||||||
|
let deviceStatus;
|
||||||
|
try {
|
||||||
|
deviceStatus = await getDeviceBatteryAPI();
|
||||||
|
} catch (err) {
|
||||||
|
isStartingOta.value = false;
|
||||||
|
uni.showToast({
|
||||||
|
title: "获取设备状态失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceStatus?.online !== true) {
|
||||||
|
isStartingOta.value = false;
|
||||||
|
uni.showToast({
|
||||||
|
title: "请先开启智能弓",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(deviceStatus?.netType || "").toLowerCase() === "wifi") {
|
||||||
|
startHomeOtaUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isStartingOta.value = false;
|
||||||
|
otaVisible.value = false;
|
||||||
|
uni.navigateTo({ url: getOtaWifiUrl() });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 OTA 更新成功后的完成按钮,关闭结果弹窗。
|
||||||
|
const handleOtaDone = () => {
|
||||||
|
otaVisible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 OTA 更新失败后的重试按钮,重新走立即更新判断流程。
|
||||||
|
const handleOtaRetry = () => {
|
||||||
|
invalidateOtaUpdateRun();
|
||||||
|
handleOtaUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
|
// 提取积分榜接口返回的榜单数组,兼容数组和对象两种返回格式。
|
||||||
const getScoreRankData = (result) => {
|
const getScoreRankData = (result) => {
|
||||||
if (Array.isArray(result)) return result;
|
if (Array.isArray(result)) return result;
|
||||||
@@ -49,11 +327,6 @@ const toPage = async (path) => {
|
|||||||
showModal.value = true;
|
showModal.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// if (path === "/pages/first-try") {
|
|
||||||
// if (canEenter(user.value, device.value, online.value, path)) {
|
|
||||||
// await uni.$checkAudio();
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
uni.navigateTo({url: path});
|
uni.navigateTo({url: path});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,10 +336,18 @@ const toRankListPage = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onShow(async () => {
|
onShow(async (options) => {
|
||||||
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||||
const token = uni.getStorageSync(`${env}_token`);
|
const token = uni.getStorageSync(`${env}_token`);
|
||||||
|
|
||||||
|
// 检查是否从 OTA 更新页面返回
|
||||||
|
if (options && options.updateResult) {
|
||||||
|
otaState.value = options.updateResult;
|
||||||
|
otaVisible.value = true;
|
||||||
|
} else if (token || user.value.id) {
|
||||||
|
await checkOtaUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
if (!user.value.id && !token) {
|
if (!user.value.id && !token) {
|
||||||
// showModal.value = true;
|
// showModal.value = true;
|
||||||
// try {
|
// try {
|
||||||
@@ -127,6 +408,8 @@ onShow(async () => {
|
|||||||
);
|
);
|
||||||
const data = await getDeviceBatteryAPI();
|
const data = await getDeviceBatteryAPI();
|
||||||
updateOnline(data.online);
|
updateOnline(data.online);
|
||||||
|
} else {
|
||||||
|
clearDevice();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -138,6 +421,10 @@ onMounted(async () => {
|
|||||||
console.log("全局配置:", config);
|
console.log("全局配置:", config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
invalidateOtaUpdateRun();
|
||||||
|
});
|
||||||
|
|
||||||
onShareAppMessage(() => {
|
onShareAppMessage(() => {
|
||||||
return {
|
return {
|
||||||
title: "智能真弓:实时捕捉+毫秒级同步,弓箭选手全球竞技!", // 分享卡片的标题
|
title: "智能真弓:实时捕捉+毫秒级同步,弓箭选手全球竞技!", // 分享卡片的标题
|
||||||
@@ -158,6 +445,21 @@ onShareTimeline(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container :isHome="true" :showBackToGame="true">
|
<Container :isHome="true" :showBackToGame="true">
|
||||||
|
<!-- OTA 升级弹窗:使用 visible 控制显隐,description 为副标题,changelog 为详细说明 -->
|
||||||
|
<OtaModal
|
||||||
|
:visible="otaVisible"
|
||||||
|
:state="otaState"
|
||||||
|
:version="otaInfo.versionNumber"
|
||||||
|
:progress="otaProgress"
|
||||||
|
:description="''"
|
||||||
|
:changelog="otaInfo.versionInfo"
|
||||||
|
:forceUpdate="otaInfo.forceUpdate"
|
||||||
|
@update="handleOtaUpdate"
|
||||||
|
@skip="handleOtaDismiss"
|
||||||
|
@close="handleOtaDismiss"
|
||||||
|
@done="handleOtaDone"
|
||||||
|
@retry="handleOtaRetry"
|
||||||
|
/>
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<view class="top-theme">
|
<view class="top-theme">
|
||||||
<!-- <image
|
<!-- <image
|
||||||
@@ -187,7 +489,7 @@ onShareTimeline(() => {
|
|||||||
<text v-else-if="online">设备在线</text>
|
<text v-else-if="online">设备在线</text>
|
||||||
</block>
|
</block>
|
||||||
<image
|
<image
|
||||||
src="../static/first-try.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/first-try.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
@click="() => toPage('/pages/first-try')"
|
@click="() => toPage('/pages/first-try')"
|
||||||
/>
|
/>
|
||||||
@@ -197,11 +499,12 @@ onShareTimeline(() => {
|
|||||||
</BubbleTip>
|
</BubbleTip>
|
||||||
</view>
|
</view>
|
||||||
<view class="play-card">
|
<view class="play-card">
|
||||||
<view @click="$clickSound(() => toPage('/pages/practise'))">
|
<!-- toPage('/pages/practise') -->
|
||||||
<image src="../static/my-practise.png" mode="widthFix"/>
|
<view @click="$clickSound(() => toPage('/pages/training/index'))">
|
||||||
|
<image src="https://static.shelingxingqiu.com/shootmini/static/my-practise.png" mode="widthFix"/>
|
||||||
</view>
|
</view>
|
||||||
<view @click="$clickSound(() => toPage('/pages/friend-battle'))">
|
<view @click="$clickSound(() => toPage('/pages/friend-battle'))">
|
||||||
<image src="../static/friend-battle.png" mode="widthFix"/>
|
<image src="https://static.shelingxingqiu.com/shootmini/static/friend-battle.png" mode="widthFix"/>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -216,7 +519,7 @@ onShareTimeline(() => {
|
|||||||
hover-class="none"
|
hover-class="none"
|
||||||
></button>
|
></button>
|
||||||
<view class="ranking-players" @click="toRankListPage">
|
<view class="ranking-players" @click="toRankListPage">
|
||||||
<img src="../static/juezhanbang.png" mode="widthFix"/>
|
<img src="https://static.shelingxingqiu.com/shootmini/static/juezhanbang.png" mode="widthFix"/>
|
||||||
<view class="divide-line"></view>
|
<view class="divide-line"></view>
|
||||||
<view class="player-avatars">
|
<view class="player-avatars">
|
||||||
<view
|
<view
|
||||||
@@ -250,7 +553,7 @@ onShareTimeline(() => {
|
|||||||
</view>
|
</view>
|
||||||
<view class="my-data">
|
<view class="my-data">
|
||||||
<view @click="() => toPage('/pages/my-growth')">
|
<view @click="() => toPage('/pages/my-growth')">
|
||||||
<image src="../static/my-growth.png" mode="widthFix"/>
|
<image src="https://static.shelingxingqiu.com/shootmini/static/my-growth.png" mode="widthFix"/>
|
||||||
</view>
|
</view>
|
||||||
<view @click="() => toPage('/pages/ranking')">
|
<view @click="() => toPage('/pages/ranking')">
|
||||||
<view>
|
<view>
|
||||||
@@ -279,6 +582,7 @@ onShareTimeline(() => {
|
|||||||
</view>
|
</view>
|
||||||
<Signin :show="showModal" :onClose="() => (showModal = false)"/>
|
<Signin :show="showModal" :onClose="() => (showModal = false)"/>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="foot-space"></view>
|
||||||
<AppFooter/>
|
<AppFooter/>
|
||||||
</Container>
|
</Container>
|
||||||
</template>
|
</template>
|
||||||
@@ -286,9 +590,12 @@ onShareTimeline(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.container {
|
.container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: calc(100% - 120px);
|
/* height: calc(100% + 240rpx); */
|
||||||
|
}
|
||||||
|
.foot-space{
|
||||||
|
width: 100%;
|
||||||
|
height: 200rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-grid {
|
.feature-grid {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -448,7 +755,7 @@ onShareTimeline(() => {
|
|||||||
|
|
||||||
.my-data > view:nth-child(2) {
|
.my-data > view:nth-child(2) {
|
||||||
width: 68%;
|
width: 68%;
|
||||||
font-size: 12px;
|
font-size: 24rpx;
|
||||||
color: #fff6;
|
color: #fff6;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ const data = ref({
|
|||||||
rounds: [],
|
rounds: [],
|
||||||
});
|
});
|
||||||
const players = ref([]);
|
const players = ref([]);
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const loadError = ref("");
|
||||||
|
|
||||||
onLoad(async (options) => {
|
const loadBattle = async () => {
|
||||||
if (!options.battleId) return;
|
|
||||||
battleId.value = options.battleId || "60510101693403136";
|
|
||||||
const result = await getBattleAPI(battleId.value);
|
const result = await getBattleAPI(battleId.value);
|
||||||
data.value = result;
|
data.value = result;
|
||||||
if (result.mode > 3) {
|
if (result.mode > 3) {
|
||||||
@@ -62,12 +62,38 @@ onLoad(async (options) => {
|
|||||||
|
|
||||||
players.value = [...rankedPlayers, ...unrankedPlayers];
|
players.value = [...rankedPlayers, ...unrankedPlayers];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 数据和派生列表均已就绪后再关闭占位,避免空壳闪烁。
|
||||||
|
isLoading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestBattle = () => {
|
||||||
|
if (!battleId.value) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
loadError.value = "";
|
||||||
|
loadBattle().catch((error) => {
|
||||||
|
console.error("加载比赛详情失败:", error);
|
||||||
|
loadError.value = "赛况加载失败";
|
||||||
|
isLoading.value = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (!options.battleId) {
|
||||||
|
loadError.value = "缺少比赛信息";
|
||||||
|
isLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
battleId.value = options.battleId;
|
||||||
|
requestBattle();
|
||||||
});
|
});
|
||||||
|
|
||||||
const checkBowData = (selected) => {
|
const checkBowData = (selected) => {
|
||||||
if (data.value.mode <= 3) {
|
if (data.value.mode <= 3) {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/team-bow-data?battleId=${battleId.value}&selected=${selected}`,
|
url: `/pages/team-battle/team-bow-data?battleId=${battleId.value}&selected=${selected}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
@@ -80,6 +106,17 @@ const checkBowData = (selected) => {
|
|||||||
<template>
|
<template>
|
||||||
<Container title="详情">
|
<Container title="详情">
|
||||||
<view class="container">
|
<view class="container">
|
||||||
|
<view v-if="isLoading" class="page-state">
|
||||||
|
<text>赛况加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-else-if="loadError"
|
||||||
|
class="page-state page-state--error"
|
||||||
|
@click="requestBattle"
|
||||||
|
>
|
||||||
|
<text>{{ loadError }}</text>
|
||||||
|
<text v-if="battleId" class="retry-text">点击重新加载</text>
|
||||||
|
</view>
|
||||||
<BattleHeader
|
<BattleHeader
|
||||||
v-if="data.mode <= 3"
|
v-if="data.mode <= 3"
|
||||||
:winner="data.winTeam"
|
:winner="data.winTeam"
|
||||||
@@ -165,6 +202,22 @@ const checkBowData = (selected) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
.page-state {
|
||||||
|
min-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
.page-state--error {
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
}
|
||||||
|
.retry-text {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
color: #fed847;
|
||||||
|
}
|
||||||
.score-header,
|
.score-header,
|
||||||
.score-row {
|
.score-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
+156
-25
@@ -3,18 +3,28 @@ import { ref, onMounted, onBeforeUnmount } from "vue";
|
|||||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import Matching from "@/components/Matching.vue";
|
import Matching from "@/components/Matching.vue";
|
||||||
|
import ModalDialog from "@/components/ModalDialog.vue";
|
||||||
|
import audioManager from "@/audioManager";
|
||||||
import { matchGameAPI, getBattleAPI } from "@/apis";
|
import { matchGameAPI, getBattleAPI } from "@/apis";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
|
import { isLimitError } from "@/util";
|
||||||
|
import { returnToBattle } from "@/utils/matchReturn";
|
||||||
|
|
||||||
const gameType = ref(0);
|
const gameType = ref(0);
|
||||||
const teamSize = ref(0);
|
const teamSize = ref(0);
|
||||||
const onComplete = ref(null);
|
const onComplete = ref(null);
|
||||||
|
const showLimitModal = ref(false);
|
||||||
|
const navigationPending = ref(false);
|
||||||
|
let pageMounted = false;
|
||||||
|
let pageVisible = false;
|
||||||
|
let foregroundGeneration = 0;
|
||||||
|
|
||||||
/** 匹配超时计时器,用于检测 WS 消息丢失或真正超时 */
|
/** 匹配超时计时器,用于检测 WS 消息丢失或真正超时 */
|
||||||
const matchTimeoutTimer = ref(null);
|
const matchTimeoutTimer = ref(null);
|
||||||
|
|
||||||
/** 匹配超时阈值(ms),后端设置 15s 匹配,前端预留 5s 冗余 */
|
/** 匹配超时阈值(ms),后端设置 15s 匹配,前端预留 5s 冗余 */
|
||||||
const MATCH_TIMEOUT_MS = 20 * 1000;
|
const MATCH_TIMEOUT_MS = 20 * 1000;
|
||||||
|
const MATCH_INFO_RETRY_DELAY_MS = 500;
|
||||||
|
|
||||||
/** 清除超时计时器 */
|
/** 清除超时计时器 */
|
||||||
const clearMatchTimeout = () => {
|
const clearMatchTimeout = () => {
|
||||||
@@ -24,21 +34,110 @@ const clearMatchTimeout = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wait = (delay) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isCurrentForeground = (generation) =>
|
||||||
|
pageMounted &&
|
||||||
|
pageVisible &&
|
||||||
|
generation === foregroundGeneration &&
|
||||||
|
!navigationPending.value;
|
||||||
|
|
||||||
|
const redirectOnce = (url) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
if (navigationPending.value) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigationPending.value = true;
|
||||||
|
clearMatchTimeout();
|
||||||
|
uni.redirectTo({
|
||||||
|
url,
|
||||||
|
success: resolve,
|
||||||
|
fail: (error) => {
|
||||||
|
navigationPending.value = false;
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function queryCurrentBattle(generation) {
|
||||||
|
try {
|
||||||
|
return await getBattleAPI();
|
||||||
|
} catch (error) {
|
||||||
|
await wait(MATCH_INFO_RETRY_DELAY_MS);
|
||||||
|
if (!isCurrentForeground(generation)) throw error;
|
||||||
|
return getBattleAPI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function returnToCurrentBattle(battle) {
|
||||||
|
if (!battle?.matchId || navigationPending.value) return false;
|
||||||
|
return returnToBattle(battle, redirectOnce);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reconcileMatchingPage(generation) {
|
||||||
|
clearMatchTimeout();
|
||||||
|
|
||||||
|
let battle = null;
|
||||||
|
try {
|
||||||
|
battle = await queryCurrentBattle(generation);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
console.log("resume match info error", error);
|
||||||
|
uni.showToast({
|
||||||
|
title: "比赛状态查询失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
if (battle?.matchId) {
|
||||||
|
try {
|
||||||
|
await returnToCurrentBattle(battle);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("return to current match error", error);
|
||||||
|
uni.showToast({
|
||||||
|
title: "进入比赛失败,请重试",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!gameType.value || !teamSize.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await matchGameAPI(true, gameType.value, teamSize.value);
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
matchTimeoutTimer.value = setTimeout(handleMatchTimeout, MATCH_TIMEOUT_MS);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentForeground(generation)) return;
|
||||||
|
clearMatchTimeout();
|
||||||
|
if (isLimitError(error)) {
|
||||||
|
showLimitModal.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.navigateBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 超时处理:查询后端是否已分配对局
|
* 超时处理:查询后端是否已分配对局
|
||||||
* - 有对局 → WS 消息丢失场景,自动跳入
|
* - 有对局 → WS 消息丢失场景,自动跳入
|
||||||
* - 无对局 → 真正超时,提示用户并返回大厅
|
* - 无对局 → 真正超时,提示用户并返回大厅
|
||||||
*/
|
*/
|
||||||
const handleMatchTimeout = async () => {
|
const handleMatchTimeout = async () => {
|
||||||
|
if (!pageVisible || navigationPending.value) return;
|
||||||
try {
|
try {
|
||||||
const battle = await getBattleAPI();
|
const battle = await getBattleAPI();
|
||||||
if (battle && battle.matchId) {
|
if (battle && battle.matchId) {
|
||||||
uni.showToast({ title: "匹配成功,正在进入...", icon: "none" });
|
uni.showToast({ title: "匹配成功,正在进入...", icon: "none" });
|
||||||
if (battle.mode <= 3) {
|
await returnToCurrentBattle(battle);
|
||||||
uni.redirectTo({ url: `/pages/team-battle/index?battleId=${battle.matchId}` });
|
|
||||||
} else {
|
|
||||||
uni.redirectTo({ url: `/pages/melee-battle?battleId=${battle.matchId}` });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
uni.showToast({ title: "匹配超时,请重试", icon: "none" });
|
uni.showToast({ title: "匹配超时,请重试", icon: "none" });
|
||||||
try {
|
try {
|
||||||
@@ -58,12 +157,26 @@ async function stopMatch() {
|
|||||||
uni.$showHint(3);
|
uni.$showHint(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeLimitModal = () => {
|
||||||
|
showLimitModal.value = false;
|
||||||
|
uni.navigateBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goVipPage = () => {
|
||||||
|
showLimitModal.value = false;
|
||||||
|
uni.redirectTo({
|
||||||
|
url: "/pages/member/be-vip",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 取消匹配,带容错处理:
|
* 取消匹配,带容错处理:
|
||||||
* - 取消成功 → 返回大厅
|
* - 取消成功 → 返回大厅
|
||||||
* - 取消失败(后端已分配对局但 WS 未到达)→ 查询并跳入当前对局
|
* - 取消失败(后端已分配对局但 WS 未到达)→ 查询并跳入当前对局
|
||||||
*/
|
*/
|
||||||
async function cancelMatch() {
|
async function cancelMatch() {
|
||||||
|
if (navigationPending.value) return;
|
||||||
|
foregroundGeneration += 1;
|
||||||
clearMatchTimeout();
|
clearMatchTimeout();
|
||||||
try {
|
try {
|
||||||
if (gameType.value && teamSize.value) {
|
if (gameType.value && teamSize.value) {
|
||||||
@@ -75,11 +188,7 @@ async function cancelMatch() {
|
|||||||
try {
|
try {
|
||||||
const battle = await getBattleAPI();
|
const battle = await getBattleAPI();
|
||||||
if (battle && battle.matchId) {
|
if (battle && battle.matchId) {
|
||||||
if (battle.mode <= 3) {
|
await returnToCurrentBattle(battle);
|
||||||
uni.redirectTo({ url: `/pages/team-battle/index?battleId=${battle.matchId}` });
|
|
||||||
} else {
|
|
||||||
uni.redirectTo({ url: `/pages/melee-battle?battleId=${battle.matchId}` });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
uni.navigateBack();
|
uni.navigateBack();
|
||||||
}
|
}
|
||||||
@@ -94,18 +203,21 @@ async function onReceiveMessage(msg) {
|
|||||||
onComplete.value = () => {}
|
onComplete.value = () => {}
|
||||||
}
|
}
|
||||||
if (msg.type === MESSAGETYPESV2.AboutToStart) {
|
if (msg.type === MESSAGETYPESV2.AboutToStart) {
|
||||||
|
if (navigationPending.value) return;
|
||||||
// 收到开始消息,清除超时计时器
|
// 收到开始消息,清除超时计时器
|
||||||
clearMatchTimeout();
|
clearMatchTimeout();
|
||||||
// 使用后端下发的 mode 字段判断跳转目标,与好友约战(battle-room.vue)保持一致
|
// 使用后端下发的 mode 字段判断跳转目标,与好友约战(battle-room.vue)保持一致
|
||||||
// mode <= 3 为团队对抗,mode > 3 为大乱斗,覆盖全部 gameType(1~5),不再遗漏
|
// mode <= 3 为团队对抗,mode > 3 为大乱斗,覆盖全部 gameType(1~5),不再遗漏
|
||||||
if (msg.mode <= 3) {
|
const matchId = msg.matchId || msg.id;
|
||||||
uni.redirectTo({
|
if (!matchId) return;
|
||||||
url: `/pages/team-battle/index?battleId=${msg.id}`,
|
const url =
|
||||||
});
|
msg.mode <= 3
|
||||||
} else {
|
? `/pages/team-battle/index?battleId=${matchId}`
|
||||||
uni.redirectTo({
|
: `/pages/melee-battle?battleId=${matchId}`;
|
||||||
url: `/pages/melee-battle?battleId=${msg.id}`,
|
try {
|
||||||
});
|
await redirectOnce(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("redirect to matched battle error", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,17 +227,27 @@ onLoad(async (options) => {
|
|||||||
gameType.value = options.gameType;
|
gameType.value = options.gameType;
|
||||||
teamSize.value = options.teamSize;
|
teamSize.value = options.teamSize;
|
||||||
}
|
}
|
||||||
|
if (pageMounted && pageVisible) {
|
||||||
|
const generation = ++foregroundGeneration;
|
||||||
|
void reconcileMatchingPage(generation);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
void audioManager.warmCommon();
|
||||||
uni.setKeepScreenOn({
|
uni.setKeepScreenOn({
|
||||||
keepScreenOn: true,
|
keepScreenOn: true,
|
||||||
});
|
});
|
||||||
uni.$on("socket-inbox", onReceiveMessage);
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
uni.$on("cancelMatching", cancelMatch);
|
uni.$on("cancelMatching", cancelMatch);
|
||||||
|
pageMounted = true;
|
||||||
|
if (pageVisible) void reconcileMatchingPage(foregroundGeneration);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
pageMounted = false;
|
||||||
|
pageVisible = false;
|
||||||
|
foregroundGeneration += 1;
|
||||||
clearMatchTimeout();
|
clearMatchTimeout();
|
||||||
uni.setKeepScreenOn({
|
uni.setKeepScreenOn({
|
||||||
keepScreenOn: false,
|
keepScreenOn: false,
|
||||||
@@ -134,17 +256,18 @@ onBeforeUnmount(() => {
|
|||||||
uni.$off("cancelMatching", cancelMatch);
|
uni.$off("cancelMatching", cancelMatch);
|
||||||
});
|
});
|
||||||
|
|
||||||
onShow(async () => {
|
onShow(() => {
|
||||||
if (gameType.value && teamSize.value) {
|
pageVisible = true;
|
||||||
matchGameAPI(true, gameType.value, teamSize.value);
|
navigationPending.value = false;
|
||||||
// 启动超时计时器,防止 WS 消息丢失或长时间无对手导致用户卡死
|
const generation = ++foregroundGeneration;
|
||||||
clearMatchTimeout();
|
clearMatchTimeout();
|
||||||
matchTimeoutTimer.value = setTimeout(handleMatchTimeout, MATCH_TIMEOUT_MS);
|
if (pageMounted) void reconcileMatchingPage(generation);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onHide(() => {
|
onHide(() => {
|
||||||
|
pageVisible = false;
|
||||||
|
foregroundGeneration += 1;
|
||||||
|
clearMatchTimeout();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -154,6 +277,14 @@ onHide(() => {
|
|||||||
<Matching :stopMatch="stopMatch" :onComplete="onComplete" />
|
<Matching :stopMatch="stopMatch" :onComplete="onComplete" />
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
|
<ModalDialog
|
||||||
|
:show="showLimitModal"
|
||||||
|
:content="'今日排位赛次数已经用完\n开通会员可增加次数'"
|
||||||
|
cancelText="知道了"
|
||||||
|
confirmText="去开通"
|
||||||
|
:onCancel="closeLimitModal"
|
||||||
|
:onConfirm="goVipPage"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
+327
-74
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
|
import { ref, onMounted, onBeforeUnmount, watch, nextTick, computed } from "vue";
|
||||||
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
import { onLoad, onShow, onHide } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import BowTarget from "@/components/BowTarget.vue";
|
import BowTarget from "@/components/BowTarget.vue";
|
||||||
@@ -8,12 +8,20 @@ import BattleHeader from "@/components/BattleHeader.vue";
|
|||||||
import PlayerScore from "@/components/PlayerScore.vue";
|
import PlayerScore from "@/components/PlayerScore.vue";
|
||||||
import SButton from "@/components/SButton.vue";
|
import SButton from "@/components/SButton.vue";
|
||||||
import Avatar from "@/components/Avatar.vue";
|
import Avatar from "@/components/Avatar.vue";
|
||||||
|
import BowPower from "@/components/BowPower.vue";
|
||||||
import ScreenHint from "@/components/ScreenHint.vue";
|
import ScreenHint from "@/components/ScreenHint.vue";
|
||||||
import TestDistance from "@/components/TestDistance.vue";
|
import TestDistance from "@/components/TestDistance.vue";
|
||||||
import SModal from "@/components/SModal.vue";
|
import SModal from "@/components/SModal.vue";
|
||||||
import audioManager from "@/audioManager";
|
import audioManager from "@/audioManager";
|
||||||
import { getBattleAPI, laserCloseAPI } from "@/apis";
|
import { getBattleAPI, laserCloseAPI } from "@/apis";
|
||||||
|
import {
|
||||||
|
closeMatchWebSocket,
|
||||||
|
connectMatchWebSocket,
|
||||||
|
MATCH_WS_AUDIO_ACK_EVENT,
|
||||||
|
MATCH_WS_STATE_EVENT,
|
||||||
|
} from "@/matchWebsocket";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
|
import { takeMatchReturnSnapshot } from "@/utils/matchReturn";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -21,6 +29,7 @@ const { user, online } = storeToRefs(store);
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
const start = ref(null);
|
const start = ref(null);
|
||||||
const battleId = ref("");
|
const battleId = ref("");
|
||||||
|
const targetType = ref(20);
|
||||||
/** 对战模式:1=好友约战 2=排位赛,用于结算页跳转判断 */
|
/** 对战模式:1=好友约战 2=排位赛,用于结算页跳转判断 */
|
||||||
const way = ref(0);
|
const way = ref(0);
|
||||||
const currentRound = ref(1);
|
const currentRound = ref(1);
|
||||||
@@ -30,10 +39,209 @@ const playersSorted = ref([]);
|
|||||||
const playersScores = ref([]);
|
const playersScores = ref([]);
|
||||||
const halfTimeTip = ref(false);
|
const halfTimeTip = ref(false);
|
||||||
const halfRest = ref(false);
|
const halfRest = ref(false);
|
||||||
|
const DEFAULT_READY_TIME = 15;
|
||||||
|
const HALF_REST_SECONDS = 20;
|
||||||
|
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
|
||||||
|
const MATCH_STATUS_BY_TEXT = {
|
||||||
|
MATCH_STATUS_READY: 0,
|
||||||
|
MATCH_STATUS_STARTED: 1,
|
||||||
|
MATCH_STATUS_END: 2,
|
||||||
|
MATCH_STATUS_TIMEOUT: 3,
|
||||||
|
MATCH_STATUS_UNEXPECTEDLY: 4,
|
||||||
|
};
|
||||||
|
const halfRestRemain = ref(HALF_REST_SECONDS);
|
||||||
|
const readyTime = ref(DEFAULT_READY_TIME);
|
||||||
|
let halfRestTimer = null;
|
||||||
/** 控制设备离线提示弹窗的显示状态 */
|
/** 控制设备离线提示弹窗的显示状态 */
|
||||||
const showOfflineModal = ref(false);
|
const showOfflineModal = ref(false);
|
||||||
/** 记录每位玩家当前半场连续 X 环数,key 为 playerId,用于触发 tententen 音效 */
|
let battleEnded = false;
|
||||||
const xRingStreaks = ref({});
|
let skipNextRestoreOnShow = false;
|
||||||
|
let restoreGeneration = 0;
|
||||||
|
|
||||||
|
function clearHalfRestCountdown() {
|
||||||
|
if (halfRestTimer) {
|
||||||
|
clearInterval(halfRestTimer);
|
||||||
|
halfRestTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveHalfRest() {
|
||||||
|
clearHalfRestCountdown();
|
||||||
|
halfTimeTip.value = false;
|
||||||
|
halfRest.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHalfRestSeconds(battleInfo) {
|
||||||
|
const remainCandidates = [
|
||||||
|
battleInfo?.halfRestRemain,
|
||||||
|
battleInfo?.halfRestRemainSeconds,
|
||||||
|
battleInfo?.restRemain,
|
||||||
|
battleInfo?.restRemainSeconds,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const item of remainCandidates) {
|
||||||
|
const remain = Number(item);
|
||||||
|
if (Number.isFinite(remain) && remain > 0 && remain <= HALF_REST_SECONDS) {
|
||||||
|
return Math.ceil(remain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutTime = normalizeTimestamp(battleInfo?.timeoutTime);
|
||||||
|
if (timeoutTime) {
|
||||||
|
const elapsedSeconds = Math.max(0, (Date.now() - timeoutTime) / 1000);
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(HALF_REST_SECONDS, Math.ceil(HALF_REST_SECONDS - elapsedSeconds))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const endTime = normalizeTimestamp(
|
||||||
|
battleInfo?.halfRestEndTime ?? battleInfo?.restEndTime
|
||||||
|
);
|
||||||
|
if (endTime) {
|
||||||
|
const diffSeconds = (endTime - Date.now()) / 1000;
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(HALF_REST_SECONDS, Math.ceil(diffSeconds))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return HALF_REST_SECONDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startHalfRestCountdown(seconds = HALF_REST_SECONDS) {
|
||||||
|
clearHalfRestCountdown();
|
||||||
|
const secondsValue = Number(seconds);
|
||||||
|
const normalizedSeconds = Number.isFinite(secondsValue)
|
||||||
|
? secondsValue
|
||||||
|
: HALF_REST_SECONDS;
|
||||||
|
halfRestRemain.value = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(HALF_REST_SECONDS, Math.ceil(normalizedSeconds))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (halfRestRemain.value <= 0) return;
|
||||||
|
|
||||||
|
halfRestTimer = setInterval(() => {
|
||||||
|
if (halfRestRemain.value <= 1) {
|
||||||
|
halfRestRemain.value = 0;
|
||||||
|
clearHalfRestCountdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
halfRestRemain.value -= 1;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
const currentPlayer = computed(() =>
|
||||||
|
players.value.find((player) => String(player?.id) === String(user.value.id))
|
||||||
|
);
|
||||||
|
const isCurrentUserSvip = computed(() => currentPlayer.value?.sVip === true);
|
||||||
|
|
||||||
|
function getReadySnapshotKey(matchId) {
|
||||||
|
return `${MATCH_READY_SNAPSHOT_PREFIX}${String(matchId || "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBattleInfo(battleInfo) {
|
||||||
|
if (!battleInfo || typeof battleInfo !== "object") return battleInfo;
|
||||||
|
|
||||||
|
const statusText = battleInfo.statusText || battleInfo.status_text;
|
||||||
|
if (
|
||||||
|
(battleInfo.status === undefined ||
|
||||||
|
battleInfo.status === null ||
|
||||||
|
battleInfo.status === "") &&
|
||||||
|
MATCH_STATUS_BY_TEXT[statusText] !== undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...battleInfo,
|
||||||
|
status: MATCH_STATUS_BY_TEXT[statusText],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return battleInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimestamp(value) {
|
||||||
|
const numberValue = Number(value || 0);
|
||||||
|
if (!numberValue) return 0;
|
||||||
|
return numberValue < 1000000000000 ? numberValue * 1000 : numberValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadyTime(battleInfo) {
|
||||||
|
const value = Number(battleInfo?.readyTime);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_READY_TIME;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReadyRemainingSeconds(battleInfo) {
|
||||||
|
const total = getReadyTime(battleInfo);
|
||||||
|
const countdownStartTime = normalizeTimestamp(battleInfo?.countdownStartTime);
|
||||||
|
if (!countdownStartTime) return total;
|
||||||
|
|
||||||
|
const elapsed = Math.max(0, (Date.now() - countdownStartTime) / 1000);
|
||||||
|
console.log('11111111111111111111111111111', total, countdownStartTime, Math.max(0, (Date.now() - countdownStartTime) / 1000))
|
||||||
|
return Math.max(0, total - elapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKnownStatus(battleInfo) {
|
||||||
|
return !(
|
||||||
|
battleInfo?.status === undefined ||
|
||||||
|
battleInfo?.status === null ||
|
||||||
|
battleInfo?.status === ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStartedSnapshot(battleInfo) {
|
||||||
|
if (hasKnownStatus(battleInfo)) return Number(battleInfo.status) !== 0;
|
||||||
|
|
||||||
|
const current = battleInfo?.current || {};
|
||||||
|
return !!(
|
||||||
|
current.playerId ||
|
||||||
|
current.startTime ||
|
||||||
|
current.round ||
|
||||||
|
(Array.isArray(battleInfo?.rounds) && battleInfo.rounds.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeReadySnapshot(matchId) {
|
||||||
|
const key = getReadySnapshotKey(matchId);
|
||||||
|
const snapshot = uni.getStorageSync(key);
|
||||||
|
if (snapshot) uni.removeStorageSync(key);
|
||||||
|
return normalizeBattleInfo(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectMatchServer(battleInfo) {
|
||||||
|
const status = Number(battleInfo?.status);
|
||||||
|
if ([2, 4].includes(status)) return;
|
||||||
|
if (!battleInfo?.serverAddr) return;
|
||||||
|
|
||||||
|
connectMatchWebSocket({
|
||||||
|
serverAddr: battleInfo.serverAddr,
|
||||||
|
matchId: battleInfo.matchId || battleId.value,
|
||||||
|
userId: user.value.id,
|
||||||
|
mode: battleInfo.mode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBattleServer(reason) {
|
||||||
|
if (battleEnded) return;
|
||||||
|
closeMatchWebSocket({ reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyMatchAudioAck(message) {
|
||||||
|
if (
|
||||||
|
message?.sequence === undefined ||
|
||||||
|
message?.sequence === null ||
|
||||||
|
message?.sequence === ""
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.$emit(MATCH_WS_AUDIO_ACK_EVENT, {
|
||||||
|
matchId: message.matchId || battleId.value,
|
||||||
|
sequence: message.sequence,
|
||||||
|
matchWsType: message.matchWsType,
|
||||||
|
matchWsTypeName: message.matchWsTypeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户
|
* 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户
|
||||||
@@ -45,7 +253,13 @@ watch(online, (newVal, oldVal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function recoverData(battleInfo, { force = false } = {}) {
|
function recoverData(battleInfo, { force = false } = {}) {
|
||||||
|
battleInfo = normalizeBattleInfo(battleInfo);
|
||||||
if (!battleInfo) return;
|
if (!battleInfo) return;
|
||||||
|
const nextTargetType = Number(battleInfo.targetType ?? battleInfo.target_type);
|
||||||
|
if ([20, 40].includes(nextTargetType)) targetType.value = nextTargetType;
|
||||||
|
if (battleInfo.status !== undefined) {
|
||||||
|
battleEnded = [2, 4].includes(Number(battleInfo.status));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (battleInfo.way === 1) title.value = "好友约战 - 大乱斗";
|
if (battleInfo.way === 1) title.value = "好友约战 - 大乱斗";
|
||||||
if (battleInfo.way === 2) title.value = "排位赛 - 大乱斗";
|
if (battleInfo.way === 2) title.value = "排位赛 - 大乱斗";
|
||||||
@@ -63,19 +277,27 @@ function recoverData(battleInfo, { force = false } = {}) {
|
|||||||
players.value = [];
|
players.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasKnownStatus(battleInfo)) {
|
||||||
start.value = battleInfo.status !== 0;
|
start.value = battleInfo.status !== 0;
|
||||||
|
} else if (!hasStartedSnapshot(battleInfo)) {
|
||||||
|
if (start.value !== true) start.value = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
start.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (battleInfo.status === 0) {
|
if (battleInfo.status === 0) {
|
||||||
const readyRemain = (Date.now() - (battleInfo.createTime || Date.now())) / 1000;
|
readyTime.value = getReadyTime(battleInfo);
|
||||||
if (readyRemain > 0 && readyRemain < 15) {
|
setTimeout(() => {
|
||||||
setTimeout(() => uni.$emit("update-timer", 15 - readyRemain - 0.2), 200);
|
uni.$emit("update-timer", getReadyRemainingSeconds(battleInfo));
|
||||||
}
|
}, 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rounds = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : [];
|
||||||
tips.value =
|
tips.value =
|
||||||
(battleInfo.rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭";
|
(rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭";
|
||||||
playersScores.value = battleInfo.rounds.map((r) => ({ ...r.shoots }));
|
playersScores.value = rounds.map((r) => ({ ...r.shoots }));
|
||||||
const totals = {};
|
const totals = {};
|
||||||
players.value.forEach((p) => {
|
players.value.forEach((p) => {
|
||||||
const total = playersScores.value.reduce((acc, round) => {
|
const total = playersScores.value.reduce((acc, round) => {
|
||||||
@@ -91,15 +313,16 @@ function recoverData(battleInfo, { force = false } = {}) {
|
|||||||
halfTimeTip.value = true;
|
halfTimeTip.value = true;
|
||||||
halfRest.value = true;
|
halfRest.value = true;
|
||||||
tips.value = "准备下半场";
|
tips.value = "准备下半场";
|
||||||
// 剩余休息时间
|
startHalfRestCountdown(getHalfRestSeconds(battleInfo));
|
||||||
// const remain = (Date.now() - battleInfo.timeoutTime) / 1000;
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.$emit("update-remain", 0);
|
uni.$emit("update-remain", 0);
|
||||||
}, 200);
|
}, 200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
leaveHalfRest();
|
||||||
if (force) {
|
if (force) {
|
||||||
const remain = (Date.now() - (battleInfo.current?.startTime || Date.now())) / 1000;
|
const ackTime = normalizeTimestamp(battleInfo.current?.ackTime);
|
||||||
|
const remain = ackTime ? Math.max(0, (Date.now() - ackTime) / 1000) : 0;
|
||||||
console.log(`当前轮已进行${remain}秒`);
|
console.log(`当前轮已进行${remain}秒`);
|
||||||
if (remain > 0 && remain < 90) {
|
if (remain > 0 && remain < 90) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -112,8 +335,65 @@ function recoverData(battleInfo, { force = false } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreLatestBattle() {
|
||||||
|
if (!battleId.value) return;
|
||||||
|
|
||||||
|
const currentRestoreId = ++restoreGeneration;
|
||||||
|
let result = null;
|
||||||
|
try {
|
||||||
|
result = normalizeBattleInfo(await getBattleAPI(battleId.value));
|
||||||
|
} catch (err) {
|
||||||
|
console.log("restore latest melee battle failed:", err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentRestoreId !== restoreGeneration || !result) return;
|
||||||
|
|
||||||
|
if (result.status === 2) {
|
||||||
|
battleEnded = true;
|
||||||
|
uni.showToast({
|
||||||
|
title: "比赛已结束",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
uni.navigateBack({
|
||||||
|
delta: 2,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectMatchServer(result);
|
||||||
|
recoverData(result, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMatchSocketState(event) {
|
||||||
|
if (event?.state !== "open" || event?.reconnected !== true) return;
|
||||||
|
if (
|
||||||
|
event.matchId &&
|
||||||
|
battleId.value &&
|
||||||
|
String(event.matchId) !== String(battleId.value)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreLatestBattle();
|
||||||
|
}
|
||||||
|
|
||||||
onLoad(async (options) => {
|
onLoad(async (options) => {
|
||||||
if (options.battleId) battleId.value = options.battleId;
|
targetType.value = 20;
|
||||||
|
const returnSnapshot = options.fromReturn ? takeMatchReturnSnapshot() : null;
|
||||||
|
skipNextRestoreOnShow = false;
|
||||||
|
if (returnSnapshot?.matchId) battleId.value = returnSnapshot.matchId;
|
||||||
|
else if (options.battleId) battleId.value = options.battleId;
|
||||||
|
if (returnSnapshot) {
|
||||||
|
skipNextRestoreOnShow = true;
|
||||||
|
reconnectMatchServer(returnSnapshot);
|
||||||
|
recoverData(returnSnapshot, { force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const readySnapshot = takeReadySnapshot(battleId.value);
|
||||||
|
if (readySnapshot?.status === 0) {
|
||||||
|
skipNextRestoreOnShow = true;
|
||||||
|
reconnectMatchServer(readySnapshot);
|
||||||
|
recoverData(readySnapshot);
|
||||||
|
}
|
||||||
// uni.enableAlertBeforeUnload({
|
// uni.enableAlertBeforeUnload({
|
||||||
// message: "离开比赛可能导致比赛失败,是否继续?",
|
// message: "离开比赛可能导致比赛失败,是否继续?",
|
||||||
// success: (res) => {
|
// success: (res) => {
|
||||||
@@ -122,62 +402,26 @@ onLoad(async (options) => {
|
|||||||
// });
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* 检测指定玩家连续 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) {
|
async function onReceiveMessage(msg) {
|
||||||
if (Array.isArray(msg)) return;
|
if (Array.isArray(msg)) return;
|
||||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
if (msg.type === MESSAGETYPESV2.AboutToStart) {
|
||||||
halfTimeTip.value = false;
|
recoverData(msg);
|
||||||
halfRest.value = false;
|
} else if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||||
|
leaveHalfRest();
|
||||||
recoverData(msg);
|
recoverData(msg);
|
||||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
} 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);
|
recoverData(msg);
|
||||||
// 对比更新后数据找出箭数增加的玩家(即本次射手),并读取其最新箭的 ring 数据
|
if (msg.shootData?.threeConsecutive10Rings === true) {
|
||||||
const newRound = playersScores.value[playersScores.value.length - 1] || {};
|
nextTick(() => audioManager.play("tententen", false));
|
||||||
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) {
|
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
|
||||||
halfTimeTip.value = true;
|
halfTimeTip.value = true;
|
||||||
halfRest.value = true;
|
halfRest.value = true;
|
||||||
tips.value = "准备下半场";
|
tips.value = "准备下半场";
|
||||||
|
startHalfRestCountdown(getHalfRestSeconds(msg));
|
||||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||||
|
battleEnded = true;
|
||||||
|
notifyMatchAudioAck(msg);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// 全部跳转到新结算页
|
// 全部跳转到新结算页
|
||||||
uni.redirectTo({
|
uni.redirectTo({
|
||||||
@@ -191,32 +435,31 @@ onMounted(async () => {
|
|||||||
keepScreenOn: true,
|
keepScreenOn: true,
|
||||||
});
|
});
|
||||||
uni.$on("socket-inbox", onReceiveMessage);
|
uni.$on("socket-inbox", onReceiveMessage);
|
||||||
|
uni.$on(MATCH_WS_STATE_EVENT, onMatchSocketState);
|
||||||
await laserCloseAPI();
|
await laserCloseAPI();
|
||||||
});
|
});
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
uni.setKeepScreenOn({
|
uni.setKeepScreenOn({
|
||||||
keepScreenOn: false,
|
keepScreenOn: false,
|
||||||
});
|
});
|
||||||
|
restoreGeneration += 1;
|
||||||
|
clearHalfRestCountdown();
|
||||||
uni.$off("socket-inbox", onReceiveMessage);
|
uni.$off("socket-inbox", onReceiveMessage);
|
||||||
|
uni.$off(MATCH_WS_STATE_EVENT, onMatchSocketState);
|
||||||
|
closeBattleServer("melee-battle-unmount");
|
||||||
audioManager.stopAll();
|
audioManager.stopAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
onShow(async () => {
|
onHide(() => {
|
||||||
if (battleId.value) {
|
closeBattleServer("melee-battle-hide");
|
||||||
const result = await getBattleAPI(battleId.value);
|
|
||||||
if (!result) return;
|
|
||||||
if (result.status === 2) {
|
|
||||||
uni.showToast({
|
|
||||||
title: "比赛已结束",
|
|
||||||
icon: "none",
|
|
||||||
});
|
});
|
||||||
uni.navigateBack({
|
|
||||||
delta: 2,
|
onShow(() => {
|
||||||
});
|
if (skipNextRestoreOnShow) {
|
||||||
} else {
|
skipNextRestoreOnShow = false;
|
||||||
recoverData(result, { force: true });
|
return;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
restoreLatestBattle();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -224,13 +467,20 @@ onShow(async () => {
|
|||||||
<Container :title="title" :bgType="1">
|
<Container :title="title" :bgType="1">
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<BattleHeader v-if="!start" :players="players" />
|
<BattleHeader v-if="!start" :players="players" />
|
||||||
<TestDistance v-if="start === false" :guide="false" :isBattle="true" />
|
<TestDistance
|
||||||
|
v-if="start === false"
|
||||||
|
:guide="false"
|
||||||
|
:isBattle="true"
|
||||||
|
:count="readyTime"
|
||||||
|
:targetType="targetType"
|
||||||
|
/>
|
||||||
<ShootProgress
|
<ShootProgress
|
||||||
:show="start"
|
:show="start"
|
||||||
:start="start && !halfRest"
|
:start="start && !halfRest"
|
||||||
:tips="tips"
|
:tips="tips"
|
||||||
:total="90"
|
:total="90"
|
||||||
:melee="true"
|
:melee="true"
|
||||||
|
:halfRest="halfRest"
|
||||||
:battleId="battleId"
|
:battleId="battleId"
|
||||||
/>
|
/>
|
||||||
<view v-if="start" class="user-row">
|
<view v-if="start" class="user-row">
|
||||||
@@ -244,7 +494,10 @@ onShow(async () => {
|
|||||||
"
|
"
|
||||||
:totalRound="12"
|
:totalRound="12"
|
||||||
:scores="playersScores.map((r) => r[user.id]).flat()"
|
:scores="playersScores.map((r) => r[user.id]).flat()"
|
||||||
|
:isSvip="isCurrentUserSvip"
|
||||||
|
:targetType="targetType"
|
||||||
:stop="halfRest"
|
:stop="halfRest"
|
||||||
|
stable-shot-effect
|
||||||
/>
|
/>
|
||||||
<view :style="{ paddingBottom: '20px' }">
|
<view :style="{ paddingBottom: '20px' }">
|
||||||
<PlayerScore
|
<PlayerScore
|
||||||
@@ -261,7 +514,7 @@ onShow(async () => {
|
|||||||
>
|
>
|
||||||
<view class="half-time-tip">
|
<view class="half-time-tip">
|
||||||
<text>上半场结束,休息一下吧:)</text>
|
<text>上半场结束,休息一下吧:)</text>
|
||||||
<text>20秒后开始下半场</text>
|
<text>{{ halfRestRemain }}秒后开始下半场</text>
|
||||||
</view>
|
</view>
|
||||||
</ScreenHint>
|
</ScreenHint>
|
||||||
<!-- 设备离线提示弹窗 -->
|
<!-- 设备离线提示弹窗 -->
|
||||||
|
|||||||
@@ -13,13 +13,31 @@ const currentUser = ref({
|
|||||||
});
|
});
|
||||||
const players = ref([]);
|
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) => {
|
onLoad(async (options) => {
|
||||||
if (!options.battleId) return;
|
if (!options.battleId) return;
|
||||||
const result = await getBattleAPI(options.battleId || "59348111700660224");
|
const result = await getBattleAPI(options.battleId || "59348111700660224");
|
||||||
|
const plist = result.teams?.[0]?.players || [];
|
||||||
players.value = result.resultList.map((item, index) => {
|
players.value = result.resultList.map((item, index) => {
|
||||||
const plist = result.teams[0] ? result.teams[0].players : [];
|
const p = plist.find((p) => String(p.id) === String(item.userId));
|
||||||
const p = plist.find((p) => p.id === item.userId);
|
const arrows = Array.from({ length: 12 }, () => ({}));
|
||||||
const arrows = new Array(12);
|
|
||||||
result.rounds.forEach((r, index) => {
|
result.rounds.forEach((r, index) => {
|
||||||
if (r.shoots[item.userId]) {
|
if (r.shoots[item.userId]) {
|
||||||
r.shoots[item.userId].forEach((s, index2) => {
|
r.shoots[item.userId].forEach((s, index2) => {
|
||||||
@@ -29,9 +47,11 @@ onLoad(async (options) => {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
...p,
|
||||||
|
userId: item.userId,
|
||||||
rank: index + 1,
|
rank: index + 1,
|
||||||
name: p.name,
|
name: p?.name || item.name,
|
||||||
avatar: p.avatar || "",
|
avatar: p?.avatar || item.avatar || "",
|
||||||
arrows,
|
arrows,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -45,7 +65,7 @@ onLoad(async (options) => {
|
|||||||
<Container title="靶纸">
|
<Container title="靶纸">
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<image
|
<image
|
||||||
src="../static/battle-header-melee.png"
|
src="https://static.shelingxingqiu.com/shootmini/static/battle-header-melee.png"
|
||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
:style="{ top: '-50rpx' }"
|
:style="{ top: '-50rpx' }"
|
||||||
/>
|
/>
|
||||||
@@ -68,18 +88,27 @@ onLoad(async (options) => {
|
|||||||
class="player-bg"
|
class="player-bg"
|
||||||
/>
|
/>
|
||||||
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
<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>
|
</view>
|
||||||
<view :style="{ marginTop: '10px' }">
|
<view :style="{ marginTop: '10px' }">
|
||||||
<BowTarget :scores="currentUser.arrows" />
|
<BowTarget
|
||||||
|
:scores="currentUser.arrows"
|
||||||
|
:isSvip="currentUser.sVip === true"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="score-text"
|
<view class="score-text"
|
||||||
><text :style="{ color: '#fed847' }">{{
|
><text :style="{ color: '#fed847' }">{{
|
||||||
currentUser.arrows.length
|
currentUser.arrows.length
|
||||||
}}</text
|
}}</text
|
||||||
>支箭,共<text :style="{ color: '#fed847' }">{{
|
>支箭,共<text :style="{ color: '#fed847' }">{{
|
||||||
currentUser.arrows.reduce((last, next) => last + next.ring, 0)
|
getRingTotal(currentUser.arrows)
|
||||||
}}</text
|
}}</text
|
||||||
>环</view
|
>环</view
|
||||||
>
|
>
|
||||||
@@ -90,7 +119,7 @@ onLoad(async (options) => {
|
|||||||
class="score-item"
|
class="score-item"
|
||||||
:style="{ width: '13vw', height: '13vw' }"
|
:style="{ width: '13vw', height: '13vw' }"
|
||||||
>
|
>
|
||||||
{{ score.ringX ? "X" : score.ring }}
|
{{ getScoreLabel(score) }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -149,6 +178,16 @@ onLoad(async (options) => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
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 {
|
.score-text {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import { getMemberAgreement } from "./agreementData";
|
||||||
|
|
||||||
|
const agreementType = ref("renew");
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
agreementType.value = options.type || "renew";
|
||||||
|
});
|
||||||
|
|
||||||
|
const agreement = computed(() => getMemberAgreement(agreementType.value));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Container :title="agreement.navTitle">
|
||||||
|
<scroll-view scroll-y class="agreement-page" :show-scrollbar="false">
|
||||||
|
<view class="content">
|
||||||
|
<view class="page-title">{{ agreement.title }}</view>
|
||||||
|
<view v-if="agreement.meta" class="meta">{{ agreement.meta }}</view>
|
||||||
|
|
||||||
|
<block v-for="(item, index) in agreement.content" :key="index">
|
||||||
|
<view v-if="item.type === 'heading'" class="section-title">
|
||||||
|
{{ item.text }}
|
||||||
|
</view>
|
||||||
|
<view v-else-if="item.type === 'list'" class="list">
|
||||||
|
<view v-for="(listItem, listIndex) in item.items" :key="listIndex" class="list-item">
|
||||||
|
<text class="list-dot">•</text>
|
||||||
|
<text class="list-text">{{ listItem }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="paragraph">
|
||||||
|
<text>{{ item.text }}</text>
|
||||||
|
</view>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<view class="company">{{ agreement.company }}</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</Container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.agreement-page {
|
||||||
|
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: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
margin-bottom: 26rpx;
|
||||||
|
color: #666666;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 34rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 42rpx 0 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph,
|
||||||
|
.list-item,
|
||||||
|
.company {
|
||||||
|
color: #333333;
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 38rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paragraph {
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin-bottom: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-dot {
|
||||||
|
width: 28rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #333333;
|
||||||
|
line-height: 38rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-text {
|
||||||
|
flex: 1;
|
||||||
|
color: #333333;
|
||||||
|
line-height: 38rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company {
|
||||||
|
margin-top: 42rpx;
|
||||||
|
padding-bottom: 30rpx;
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
export const memberAgreements = {
|
||||||
|
renew: {
|
||||||
|
navTitle: "会员自动续费服务协议",
|
||||||
|
title: "射灵星球小程序会员自动续费服务协议",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "欢迎您使用射灵星球小程序付费会员服务(以下简称“本服务”)!",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "本服务是由广州光点飞舞网络有限公司(以下简称“公司”或“我们”)为您提供。本服务为付费服务。为了保障您的权益,请在使用本服务前详细阅读并遵守本《射灵星球小程序会员服务协议(含自动续费服务规则)》(以下简称“本协议”)以及公司已发布或将来可能发布的各项服务协议与规则。您在申请开通本服务并进入购买程序前,请务必审慎阅读、充分理解各服务协议及规则,特别是免除或限制责任条款、法律适用和争议解决条款。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "当您依照本服务开通页面提示进行阅读并同意本协议,完成全部服务开通程序后(包括但不限于点击“同意”、“下一步”或“确认支付”等确认按钮,或您开始使用本服务),即表示您已充分阅读、理解并接受本协议的全部内容,您已与本服务提供方达成一致,成为“射灵星球小程序”付费会员。本协议即在您与公司之间产生法律效力,成为对双方均具有约束力的法律文件。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "一、定义及适用范围" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1.1 射灵星球小程序付费会员:指已按照服务协议及规则完成射灵星球小程序登录的用户,在签署本协议并根据本服务开通页面所展示的收费标准支付相应费用后获取的特殊资格,在本协议中简称为“会员”或“您”。公司根据业务发展可能会新增、调整会员类型或名称,实际以开通页面展示为准,这不影响您的实际权益。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1.2 付费会员权益:指用户基于其付费会员资格所享有的特殊权益,具体权益内容应以本服务购买页面展示内容及相关权益说明为准。您理解并同意,公司可能会根据设备型号、系统版本、客户端等因素开发不同的版本,不同版本实际可使用的具体权益或服务内容可能有所差别,具体以购买页面展示为准。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1.3 本协议内容同时包括公司已经发布及后续可能不断发布的关于本服务的相关协议、规则等内容。前述内容一经正式发布,并以适当的方式送达您(服务购买页面、网站公布、系统通知等),即为本协议不可分割的组成部分,您应同样遵守。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1.4 为了给会员用户提供更多选择,公司可能会与第三方合作推出联合会员服务,或为购买会员服务的用户赠送第三方会员服务。如果您选择购买或接受以上服务,则表示您理解并认可,我们仅提供射灵星球小程序付费会员服务,不对第三方的会员服务负责。第三方会员服务的权益、使用、收费规则等,将由第三方执行和向您解释。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "二、服务开通、权益内容、服务期限及收费标准" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2.1 本服务仅支持射灵星球小程序登录用户开通。您在开通本服务时,应仔细核对已登录的账号名称、会员类型、付费类型、服务期限等具体信息。因您个人原因充错账号、开通错服务类型或服务时长的,公司不予退还已收取的费用。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2.2 您可通过已有和未来新增的支付渠道或公司指定支付方式完成本服务的购买。当您根据本服务页面提示进行确认、并成功支付了会员服务费和/或完成了成为付费会员的所有程序,您将成为射灵星球小程序付费会员。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2.3 本服务的期限(以下简称“服务期限”)以您选择并成功开通的期限为准。会员计费与服务时长采用自然时间(自然月/自然年)方式计算。具体截止时间为相应续费到期自然日的对应时间点。服务期限届满后,公司将停止继续向您提供本服务。如您同时开通了多种会员服务,其消耗及重叠规则以开通页面的官方具体规则为准。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2.4 本服务的收费标准及具体权益内容以本服务开通页面所展示的为准。基于市场与业务的发展及服务权益调整,公司可能会随时调整本服务开通所需费用及/或具体权益内容。费用或权益内容调整自公布之日起生效,您在调整生效前已开通的服务将不受影响,但该服务到期后的续费开通或自动续费扣款,则需按照调整后的标准执行。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2.5 射灵星球小程序付费会员服务为虚拟内容消费,除因本服务存在重大瑕疵导致您完全无法使用等公司的违约情形、法律法规要求必须退款或公司同意退款等情形外,完成支付和购买后,不可进行退款或转让。您在会员到期前主动取消或终止会员资格的,已支付费用不予退还。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2.6 为了保护您的账号安全、防止账号被盗风险,我们会对您开通会员服务的账号登录设备及使用范围作出合理限制。您不得将账号提供给多名第三方同时使用,否则公司有权根据安全风控能力随时调整或限制您的登录及使用权限。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "三、连续购买和自动续费服务" },
|
||||||
|
{ type: "paragraph", text: "3.1 自动续费服务类型/计费周期" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "射灵星球小程序付费会员自动续费服务包含「连续包月」、「连续包年」等服务。本服务的自动续费及对应计费周期采用按自然时间(自然月/自然年)对日顺延的方式计算。即:若您在某月18日开通连续包月服务,则下一次自动扣费续费日期为次月18日;若开通当月无对应日期的(例如1月31日开通,2月无31日),则自动调整为该自然月最后一日进行扣费续费。公司可能会根据会员需求增加或调整自动续费服务类型,具体服务类型以服务开通页面展示为准。",
|
||||||
|
},
|
||||||
|
{ type: "paragraph", text: "3.2 自动续费服务说明" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(1)本自动续费服务基于您对于自动续费的需求,在您已开通会员服务的前提下,为避免您因疏忽或其他原因导致未能及时续费而中断服务,您授权公司可在您的会员服务期限到期前,从您开通本自动续费服务时所绑定的Apple ID账户余额、或绑定的第三方支付账户(包括但不限于微信支付、支付宝支付等)余额中自动代扣下一个计费周期的费用,从而延长对应的会员服务期限。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(2)自动续费扣费日期以您开通本自动续费服务的支付渠道实际扣款时间为准:如您是通过苹果公司iOS渠道开通本服务,扣费日期通常为开通服务之日起每个计费周期的对应日期前24小时内;如您是通过安卓Android渠道或直接在小程序内通过微信支付等渠道开通本服务,扣费日期通常为每个计费周期到期前1至2日。如支付渠道根据实际情况或相关平台规则自行调整扣费时间的,以实际扣款时间为准。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(3)请您关注上述账户及可扣款余额情况,保证上述账户扣款成功以确保会员服务顺利续期。如因上述账户中可扣款余额不足导致续费失败,公司有权中断或终止相应的会员权益及服务,由此导致的风险或损失将由您自行承担。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(4)为了方便您知悉自动续费情况,公司将在扣费日期前5日以站内信、小程序系统消息推送或短信等方式提示您即将发生续期扣费的信息,第三方支付渠道也可能向您发送通知提醒即将扣费。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(5)系统会在每个扣费日期自动从您开通服务时所绑定的支付账户扣费。如扣费日期因账户问题或余额不足导致续费失败,若您未主动明确取消本服务,将视为您同意公司在扣费日期后继续发出扣款尝试,一旦您的账户扣款成功,公司将继续为您提供相应会员服务权益。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(6)如您未在每个扣费日期前操作取消自动续费服务,则公司将根据此前与您达成的委托在扣费日期继续发出续费代扣指令,一旦扣款成功,公司将自动为您开通下一个计费周期的连续服务。对于已成功完成会员服务续费的费用,原则上不予退还。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(7)您在自动续费服务期间可以额外购买或通过参与活动等方式获取付费会员服务,会员服务期限将在原服务期限基础上相应延长,但如您未主动取消自动续费服务,系统仍会按照您所开通的自动续费服务进行扣费及续期。",
|
||||||
|
},
|
||||||
|
{ type: "paragraph", text: "3.3 自动续费服务的退订" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(1)您有权决定是否取消自动续费服务,如果您希望取消自动续费服务,需在每个扣费日期前(至少提前24小时)操作取消,否则将视为您同意继续授权自动续费。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "(2)购买自动续费服务后,您可在小程序的会员管理页中关闭自动续费,或通过如下第三方支付渠道方式取消自动续费服务:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
items: [
|
||||||
|
"iOS用户(Apple ID订阅):打开苹果手机“设置” -> 点击顶部的“Apple ID/机主姓名” -> 进入“订阅” -> 选择“射灵星球” -> 点击“取消订阅”;或打开“App Store” -> 点击右上角头像进入“账户” -> 点击“订阅”进行管理。",
|
||||||
|
"微信支付自动续费用户:打开微信APP -> 点击“我” -> “服务” -> “钱包” -> “支付设置” -> “自动续费” -> 选择“射灵星球小程序会员” -> 点击“关闭服务”。",
|
||||||
|
"支付宝自动续费用户:打开支付宝APP -> 点击“我的” -> “设置” -> “支付设置” -> “免密支付/自动扣款” -> 选择“射灵星球小程序会员” -> 点击“关闭服务”。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "3.4 公司有权根据市场情况、业务规划、运营策略变化等原因单方面决定停止向您提供自动续费服务,并通过公告或站内信等方式通知您,您的付费会员服务期限自当前服务期限届满之日起终止。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "四、服务使用规范与限制" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "4.1 本服务及会员权益仅限您本人使用。未经公司书面同意,禁止以任何形式赠与、借用、出租、转让、售卖或以其他方式许可他人使用该账号及账号项下的会员服务与权益。如发生泄漏、遗失、被盗等行为,而该等行为并非公司过错导致,损失将由您自行承担。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "4.2 如您存在如下违法或不当使用本服务的情形,公司有权取消您的会员资格、作废会员权益且不予退还您所支付的会员服务费用,并有权向您追偿给公司造成的损失:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
items: [
|
||||||
|
"以盗窃、利用系统漏洞、通过任何非公司官方或授权渠道获得本服务的行为;",
|
||||||
|
"利用本服务进行盈利或非法获利,或以各种形式转让、借用您的会员权益;",
|
||||||
|
"通过非法手段对本服务会员账户的服务期限、交易状态进行修改或篡改;",
|
||||||
|
"主动对公司用于保护本服务会员权益的任何安全措施技术进行破解、更改、反操作或破坏;",
|
||||||
|
"其他违反法律法规、诚实信用原则、服务协议及相关小程序运营规则的行为。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "五、服务中止、终止及变更" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "5.1 因国家或相关政府监管部门要求、发生不可抗力事件、或由于用户违反本协议约定,公司有权中止或终止向用户提供服务。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "5.2 您知悉并确认,您开通本服务后,如您中途主动取消本服务、放弃会员权益或终止资格,您将无法退还部分或全部会员服务费用。本协议终止后,用户无权要求公司继续向其提供任何服务,且不影响终止前基于本协议已产生的权利义务。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "5.3 公司可根据国家法律法规变化、业务实际变更需求、保护用户权益的需要等,不时修改本协议,并按照法律法规规定的程序及方式进行公告。如用户不同意变更后的内容,则用户有权主动停止使用本服务;如用户在变更内容生效后仍继续使用本服务,即视为用户同意该等内容的变更。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "六、责任限制与免责条款" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "6.1 您理解并同意,本服务是按照现有技术和条件所能达到的现状提供的。公司将尽最大努力确保服务的连贯性和安全性,但不能随时预见和防范技术以及其他风险,包括但不限于不可抗力、网络原因、第三方服务瑕疵等原因可能导致的服务中断、数据丢失以及其他的损失和风险。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "6.2 基于收益与赔偿相一致及公平合理的原则,如因公司原因造成本服务不正常中断或服务不可用,您所可能获得的最高赔偿额不超过本协议项下公司就该计费周期已实际收取您的相关服务费用总额。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "七、法律适用与争议解决" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "7.1 本协议的成立、生效、履行、解释及争议的解决均应适用中华人民共和国法律。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "7.2 本协议的签订地为广东省广州市南沙区。若您因本协议与公司发生任何争议,双方应尽量友好协商解决;如协商不成的,任何一方均同意应将相关争议提交至本协议签订地有管辖权的人民法院诉讼解决。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "7.3 本协议任一条款被视为废止、无效或不可执行,该条应视为可分的且并不影响本协议其余条款的有效性及可执行性。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
company: "广州光点飞舞网络有限公司",
|
||||||
|
},
|
||||||
|
deduct: {
|
||||||
|
navTitle: "扣款授权服务协议",
|
||||||
|
title: "扣费授权服务协议",
|
||||||
|
meta: "版本号:V1.0 生效日期:2026年6月22日",
|
||||||
|
content: [
|
||||||
|
{ type: "heading", text: "一、授权声明" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "本人(即授权人,以下简称“乙方”)作为射灵星球小程序的注册用户,在自愿、平等、知悉全部授权内容的基础上,同意向广州光点飞舞网络有限公司(以下简称“甲方”)及甲方委托的第三方支付机构(包含微信支付、支付宝、苹果支付等,以下简称“支付机构”)作出本扣费授权,双方就授权扣费相关事宜达成如下协议。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "二、授权主体" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "授权人(乙方):射灵星球小程序注册用户,用户ID以平台系统记录为准。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "被授权人(甲方):广州光点飞舞网络有限公司,作为会员服务提供方及扣费指令发起方。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "支付执行方:乙方绑定的第三方支付机构,受甲方委托执行扣费操作。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "三、授权内容" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "授权场景:乙方开通射灵星球会员自动续费服务后,授权甲方在每个会员周期届满前,向支付机构发起扣费指令,用于支付下一周期的会员服务费用。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "授权金额:扣费金额以乙方开通时选择的自动续费套餐对应价格为准,具体以会员购买页面公示价格为准;若价格调整,甲方将提前通过公示或通知形式告知乙方,乙方继续使用服务视为认可并接受调整后的金额。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "授权支付账户:乙方开通自动续费时绑定的微信支付、支付宝等第三方支付账户,账户信息以支付机构记录为准。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "授权扣费次数:本授权为周期性授权。在授权有效期内,甲方可按会员周期重复发起扣费指令,直至乙方依法撤销授权为止。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "四、授权有效期" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1. 本授权自乙方点击确认开通自动续费服务之日起生效,至乙方成功取消自动续费服务之日终止。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2. 若乙方注销射灵星球小程序账号,本授权自账号注销完成之日自动终止。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "3. 若因国家政策调整、支付机构规则变更、乙方支付账户注销/冻结等非甲方主观原因导致授权无法履行的,本授权自动终止。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "五、扣费与提醒规则" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1. 扣费提醒:甲方将在每个自动续费扣费日期前5日,通过微信服务通知、小程序系统消息或短信等显著方式提示乙方即将发生续期扣费的信息,以保障乙方的知情权与选择权。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2. 扣费时机:甲方将在乙方当前会员有效期届满前24小时内发起下一周期的扣费指令,支付机构根据指令从乙方授权账户中划扣对应费用。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "3. 扣费失败处理:若因账户余额不足等原因导致首次扣费失败的,甲方及支付机构可在合规范围内依法尝试补扣;若补扣仍失败或账户处于异常状态的,本期自动续费暂停,乙方会员到期后自动失效。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "4. 扣费凭证:支付机构的扣费记录作为扣费成功的有效凭证,乙方可在支付账户账单中查询明细;甲方同步为乙方提供电子扣费记录,可在会员中心查看。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "六、授权的变更与撤销" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1. 授权变更:本授权内容如需变更(如更换支付账户、调整续费套餐),乙方需先取消原自动续费服务,再重新开通新套餐。新授权自重新开通成功之日起生效,原授权同步终止。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2. 授权撤销:乙方可随时退订本自动续费服务,解约后不影响乙方已生效周期的会员服务。根据乙方开通时选择的支付执行方,具体撤销路径如下:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
items: [
|
||||||
|
"微信支付用户:可通过微信APP内(路径:我 -> 服务 -> 钱包 -> 支付设置 -> 自动续费)或在小程序会员中心页面撤销本授权。",
|
||||||
|
"支付宝用户:可通过支付宝APP内(路径:我的 -> 设置 -> 支付设置 -> 免密支付/自动扣款)或在小程序会员中心页面撤销本授权。",
|
||||||
|
"苹果支付(iOS/App Store订阅)用户:必须通过苹果系统自带的功能进行取消(路径:iOS设备“设置” -> 点击顶部的Apple ID -> 订阅 -> 选择“射灵星球” -> 取消订阅)。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "3. 退订生效时效:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "list",
|
||||||
|
items: [
|
||||||
|
"针对微信支付和支付宝:撤销操作成功后,本授权即时终止,甲方不得再发起新的扣费指令。因第三方支付平台系统结算延迟等客观原因,导致退订生效前系统已自动扣款成功的,该期会员权益将正常发放,已扣费用原则上不予退还。",
|
||||||
|
"针对苹果支付:根据苹果公司政策,乙方需在当前计费周期届满前至少24小时手动取消订阅,否则苹果系统可能会自动续订并扣款。苹果渠道的扣费与退款均由苹果公司独立处理,甲方无权干涉,因乙方未及时取消导致扣费的,由乙方自行承担或向苹果公司申诉。",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "七、免责条款" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1. 因乙方支付账户余额不足、账户冻结、挂失、注销、限额等非甲方原因导致扣费失败的,甲方不承担任何责任,由此造成的会员权益中断等后果由乙方自行承担。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2. 因支付机构系统故障、网络中断、政策调整等第三方不可抗力或外部原因导致扣费延迟、失败或错误的,甲方将协助乙方协调解决,但不承担相应的赔偿责任。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "3. 因乙方泄露支付账户密码、账号被盗用等自身保管不当原因导致的异常扣费,甲方不承担责任,乙方应自行向支付机构或公安机关主张权利。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "八、信息保密" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1. 甲方及支付机构应对乙方的授权信息、支付信息、个人身份信息严格保密,不得用于本授权以外的任何用途。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2. 除法律法规规定、司法机关或监管部门依法要求外,甲方不得向任何第三方泄露乙方的授权及支付相关信息。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "九、争议解决" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1. 本授权协议的订立、效力、履行及争议解决均适用中华人民共和国法律。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2. 因本协议产生的任何争议,双方应尽量友好协商解决;协商不成的,任何一方均可向甲方住所地(广东省广州市天河区)有管辖权的人民法院提起诉讼。",
|
||||||
|
},
|
||||||
|
{ type: "heading", text: "十、其他" },
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "1. 本协议为《射灵星球小程序会员服务协议》的配套协议,与该协议具有同等法律效力;本协议未约定事项,参照主协议执行。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "2. 甲方有权根据业务及监管要求调整本协议内容,调整后的协议将在平台公示,乙方继续使用自动续费服务视为接受调整后的内容。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
text: "3. 甲方客服联系方式:请通过射灵星球小程序内【在线客服】或发送邮件至官方客服邮箱进行反馈。",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
company: "广州光点飞舞网络有限公司",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMemberAgreement = (type) => {
|
||||||
|
return memberAgreements[type] || memberAgreements.renew;
|
||||||
|
};
|
||||||
@@ -0,0 +1,894 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
import Container from "@/components/Container.vue";
|
||||||
|
import Signin from "@/components/Signin.vue";
|
||||||
|
import { virtualPayOrderAPI, getAppConfig, getHomeData, getOrderDetailAPI } from "@/apis";
|
||||||
|
import { capsuleHeight, wxLogin } 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 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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 未过期才展示“会员生效中”样式,已过期或无值继续展示未开通样式。
|
||||||
|
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 formatPrice = (value) => {
|
||||||
|
if (value === undefined || value === null || value === "") return "";
|
||||||
|
return String(value).replace("¥", "").replace("¥", "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuPrice = (item) => {
|
||||||
|
return formatPrice(item && item.price);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuOriginalPrice = (item) => {
|
||||||
|
return formatPrice(item && item.originalPrice);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuSortValue = (item) => {
|
||||||
|
const value = item && item.sort;
|
||||||
|
if (value === undefined || value === null || value === "") return Number.MAX_SAFE_INTEGER;
|
||||||
|
const sort = Number(value);
|
||||||
|
return Number.isFinite(sort) ? sort : Number.MAX_SAFE_INTEGER;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortMenusBySort = (menus = []) => {
|
||||||
|
return menus
|
||||||
|
.map((item, index) => ({ item, index }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const sortDiff = getMenuSortValue(a.item) - getMenuSortValue(b.item);
|
||||||
|
return sortDiff || a.index - b.index;
|
||||||
|
})
|
||||||
|
.map(({ item }) => item);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 getPackages = (type) => {
|
||||||
|
return sortMenusBySort(configMenus.value)
|
||||||
|
.filter((item) => getMenuType(item) === type.key)
|
||||||
|
.map((item) => {
|
||||||
|
return {
|
||||||
|
source: item,
|
||||||
|
id: item.id,
|
||||||
|
name: getMenuName(item),
|
||||||
|
price: getMenuPrice(item),
|
||||||
|
originalPrice: getMenuOriginalPrice(item),
|
||||||
|
icon: item.icon,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 toAgreement = (type) => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/member/agreement?type=${type}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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 refreshUserAfterPay = async () => {
|
||||||
|
refreshing.value = true;
|
||||||
|
try {
|
||||||
|
const result = await getHomeData();
|
||||||
|
if (result.user) {
|
||||||
|
updateUser(result.user);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("refresh user after pay error", error);
|
||||||
|
} finally {
|
||||||
|
refreshing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVirtualPaySignData = (result) => {
|
||||||
|
// 后端可能返回字符串或对象,这里统一转成微信虚拟支付需要的 JSON 字符串。
|
||||||
|
const signDataObj = typeof result?.signData === "string" ? JSON.parse(result.signData) : result?.signData;
|
||||||
|
if (!signDataObj) return "";
|
||||||
|
|
||||||
|
// 微信虚拟支付短剧/虚拟商品模式需要购买数量;后端未返回时使用订单数量兜底。
|
||||||
|
if (!signDataObj.buyQuantity) {
|
||||||
|
signDataObj.buyQuantity = result.quantity || 1;
|
||||||
|
}
|
||||||
|
return JSON.stringify(signDataObj);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isVirtualPayCancel = (res = {}) => {
|
||||||
|
const message = String(res.errMsg || res.message || "").toLowerCase();
|
||||||
|
const errCode = Number(res.errCode);
|
||||||
|
const errno = Number(res.errno);
|
||||||
|
|
||||||
|
return message.includes("cancel") || errCode === -23 || errno === -2;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
if (typeof wx === "undefined" || !wx.requestVirtualPayment) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "当前环境不支持微信虚拟支付",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
paying.value = true;
|
||||||
|
let waitingPayment = false;
|
||||||
|
let payToast = null;
|
||||||
|
try {
|
||||||
|
// 微信虚拟支付创建订单前需要登录 code,服务端用它换取本次支付签名参数。
|
||||||
|
const wxResult = await wxLogin();
|
||||||
|
const result = await virtualPayOrderAPI(vipId, wxResult.code);
|
||||||
|
const finalSignData = getVirtualPaySignData(result);
|
||||||
|
if (!finalSignData || !result?.paySig || !result?.signature) {
|
||||||
|
uni.showToast({
|
||||||
|
title: "支付参数生成失败",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
refreshing.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wx.requestVirtualPayment({
|
||||||
|
signData: finalSignData,
|
||||||
|
paySig: result.paySig,
|
||||||
|
signature: result.signature,
|
||||||
|
mode: "short_series_goods",
|
||||||
|
async success() {
|
||||||
|
payToast = {
|
||||||
|
title: "支付成功",
|
||||||
|
icon: "success",
|
||||||
|
};
|
||||||
|
if (result?.outTradeNo) {
|
||||||
|
try {
|
||||||
|
const orderDetail = await getOrderDetailAPI(result.outTradeNo);
|
||||||
|
console.log("virtual pay order detail", orderDetail);
|
||||||
|
} catch (error) {
|
||||||
|
console.log("virtual pay order detail error", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 客户端支付成功后,会员是否真正生效仍以服务端用户信息刷新结果为准。
|
||||||
|
refreshUserAfterPay();
|
||||||
|
},
|
||||||
|
fail(res) {
|
||||||
|
console.log("virtual pay error", res);
|
||||||
|
if (isVirtualPayCancel(res)) {
|
||||||
|
payToast = {
|
||||||
|
title: "支付已取消",
|
||||||
|
icon: "none",
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payToast = {
|
||||||
|
title: res.message || "支付失败,请稍后重试",
|
||||||
|
icon: "none",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
complete() {
|
||||||
|
paying.value = false;
|
||||||
|
if (payToast) {
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.showToast(payToast);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
waitingPayment = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("create virtual pay order error", error);
|
||||||
|
uni.showToast({
|
||||||
|
title: error.message || "下单失败",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (!waitingPayment) paying.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onShow(loadVipConfig);
|
||||||
|
|
||||||
|
</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.id || pack.name || index}`"
|
||||||
|
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 v-if="pack.originalPrice" class="package-origin">
|
||||||
|
<text>¥{{ pack.originalPrice }}</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 || !selectedPackage"
|
||||||
|
@click="onPay"
|
||||||
|
>
|
||||||
|
<text v-if="loadingConfig">加载套餐中</text>
|
||||||
|
<text v-else-if="paying">创建订单中</text>
|
||||||
|
<text v-else-if="refreshing">刷新会员状态中</text>
|
||||||
|
<text v-else-if="selectedPackage">¥ {{ selectedPackage.price }} 一键激活</text>
|
||||||
|
<text v-else>套餐暂不可购买</text>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<view class="agreement">
|
||||||
|
<text>支付即同意</text>
|
||||||
|
《 <text class="agreement__link" @click.stop="toAgreement('renew')">会员自动续费服务协议</text>》
|
||||||
|
《 <text class="agreement__link" @click.stop="toAgreement('deduct')">扣款授权服务协议</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: inline-flex;
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 6rpx 84rpx 6rpx 6rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.package-card {
|
||||||
|
position: relative;
|
||||||
|
width: 264rpx;
|
||||||
|
flex: 0 0 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 cancelOrder = async () => {
|
||||||
const result = await cancelOrderListAPI(data.value.orderId);
|
const result = await cancelOrderListAPI(data.value.orderId);
|
||||||
data.value = result;
|
data.value = result;
|
||||||
@@ -78,14 +90,17 @@ const cancelOrder = async () => {
|
|||||||
>
|
>
|
||||||
<view class="order">
|
<view class="order">
|
||||||
<view>
|
<view>
|
||||||
<text>商品名:{{ data.vipName }}</text>
|
<text>{{ data.vipName }}</text>
|
||||||
|
<view class="order-number">
|
||||||
<text>订单号:{{ data.orderId }}</text>
|
<text>订单号:{{ data.orderId }}</text>
|
||||||
<text>下单时间:{{ data.vipCreateAt }}</text>
|
<text class="copy-action" @click.stop="copyOrderId(data.orderId)"
|
||||||
<text
|
>复制</text
|
||||||
>支付时间:{{
|
|
||||||
data.orderStatus === 4 ? data.paymentTime : ""
|
|
||||||
}}</text
|
|
||||||
>
|
>
|
||||||
|
</view>
|
||||||
|
<text>创建时间:{{ data.orderCreateAt }}</text>
|
||||||
|
<text v-if="data.orderStatus === 4">支付时间:{{
|
||||||
|
data.orderStatus === 4 ? data.paymentTime : ""
|
||||||
|
}}</text>
|
||||||
<text>金额:{{ data.total }} 元</text>
|
<text>金额:{{ data.total }} 元</text>
|
||||||
<text>支付方式:微信</text>
|
<text>支付方式:微信</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -141,4 +156,25 @@ const cancelOrder = async () => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 11px;
|
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>
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from "vue";
|
|||||||
import { onShow } from "@dcloudio/uni-app";
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import ScrollList from "@/components/ScrollList.vue";
|
import ScrollList from "@/components/ScrollList.vue";
|
||||||
|
import ModalDialog from "@/components/ModalDialog.vue";
|
||||||
import { getOrderListAPI } from "@/apis";
|
import { getOrderListAPI } from "@/apis";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { orderStatusNames, getStatusColor } from "@/constants";
|
import { orderStatusNames, getStatusColor } from "@/constants";
|
||||||
@@ -10,13 +11,34 @@ import { storeToRefs } from "pinia";
|
|||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user, config } = storeToRefs(store);
|
const { user, config } = storeToRefs(store);
|
||||||
|
|
||||||
|
const autoRenewDialogVisible = ref(false);
|
||||||
|
const selectedRenewOrder = ref(null);
|
||||||
|
|
||||||
const toDetailPage = (detail) => {
|
const toDetailPage = (detail) => {
|
||||||
uni.setStorageSync("order", detail);
|
uni.setStorageSync("order", detail);
|
||||||
uni.navigateTo({
|
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 list = ref([]);
|
||||||
|
|
||||||
const onLoading = async (page) => {
|
const onLoading = async (page) => {
|
||||||
@@ -44,7 +66,7 @@ onShow(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Container title="订单">
|
<Container title="订单管理">
|
||||||
<view class="container">
|
<view class="container">
|
||||||
<ScrollList :onLoading="onLoading">
|
<ScrollList :onLoading="onLoading">
|
||||||
<view
|
<view
|
||||||
@@ -58,18 +80,31 @@ onShow(() => {
|
|||||||
>{{ orderStatusNames[item.orderStatus] }}</view
|
>{{ orderStatusNames[item.orderStatus] }}</view
|
||||||
>
|
>
|
||||||
<text>{{ item.vipName }}</text>
|
<text>{{ item.vipName }}</text>
|
||||||
<!-- <text>订单号:{{ item.orderId }}</text> -->
|
<text>订单号:{{ item.orderId }}</text>
|
||||||
<!-- <text>创建时间:{{ item.vipCreateAt }}</text> -->
|
<text>创建时间:{{ item.orderCreateAt }}</text>
|
||||||
<text
|
<!-- <text
|
||||||
>支付时间:{{
|
>支付时间:{{
|
||||||
item.orderStatus === 4 ? item.paymentTime : ""
|
item.orderStatus === 4 ? item.paymentTime : ""
|
||||||
}}</text
|
}}</text
|
||||||
>
|
> -->
|
||||||
<text>金额:{{ item.total }} 元</text>
|
<text>金额:{{ item.total }} 元</text>
|
||||||
<text>支付方式:微信</text>
|
<!-- <text>支付方式:微信</text> -->
|
||||||
|
<!-- <text class="renew-action" @click.stop="openAutoRenewDialog(item)">
|
||||||
|
自动续费
|
||||||
|
</text> -->
|
||||||
</view>
|
</view>
|
||||||
</ScrollList>
|
</ScrollList>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<ModalDialog
|
||||||
|
:show="autoRenewDialogVisible"
|
||||||
|
title=""
|
||||||
|
:content="'确定关闭自动续费吗?\n会员到期后你将失去7项特权哦!'"
|
||||||
|
cancel-text="一意孤行"
|
||||||
|
confirm-text="继续享受"
|
||||||
|
:on-cancel="closeAutoRenewDialog"
|
||||||
|
:on-confirm="confirmAutoRenewDialog"
|
||||||
|
/>
|
||||||
</Container>
|
</Container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -78,15 +113,15 @@ onShow(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
padding-top: 10px;
|
padding-top: 16rpx;
|
||||||
}
|
}
|
||||||
.order-item {
|
.order-item {
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 16rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 15px;
|
padding: 28rpx 30rpx 18rpx 30rpx;
|
||||||
}
|
}
|
||||||
.order-item > view:first-child {
|
.order-item > view:first-child {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -98,12 +133,20 @@ onShow(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.order-item > text:nth-child(2) {
|
.order-item > text:nth-child(2) {
|
||||||
color: #000;
|
color: #333333;
|
||||||
font-size: 16px;
|
font-size: 30rpx;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.order-item > text {
|
.order-item > text {
|
||||||
color: #666666;
|
color: #666666;
|
||||||
font-size: 13px;
|
font-size: 26rpx;
|
||||||
margin-top: 5px;
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
.order-item > .renew-action {
|
||||||
|
position: absolute;
|
||||||
|
right: 30rpx;
|
||||||
|
bottom: 18rpx;
|
||||||
|
color: #1f6ed4;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -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>
|
||||||
+74
-15
@@ -1,23 +1,61 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onLoad } from "@dcloudio/uni-app";
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import Avatar from "@/components/Avatar.vue";
|
import Avatar from "@/components/Avatar.vue";
|
||||||
import BowTarget from "@/components/BowTarget.vue";
|
import BowTarget from "@/components/BowTarget.vue";
|
||||||
import ScorePanel from "@/components/ScorePanel.vue";
|
import ScorePanel2 from "@/components/TrainingScorePanel.vue";
|
||||||
import { getPractiseAPI } from "@/apis";
|
import { getPractiseDetailAPI } from "@/apis";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { user } = storeToRefs(store);
|
const { user } = storeToRefs(store);
|
||||||
const arrows = ref([]);
|
const arrows = ref([]);
|
||||||
const total = ref(0);
|
const isSvip = ref(false);
|
||||||
|
const practiseDetail = ref({});
|
||||||
|
|
||||||
|
const trainingType = computed(() =>
|
||||||
|
String(practiseDetail.value.trainingType || "").trim().toLowerCase()
|
||||||
|
);
|
||||||
|
const targetTypeText = computed(() => {
|
||||||
|
const targetType = Number(practiseDetail.value.targetType);
|
||||||
|
return targetType > 0 ? `${targetType}CM靶` : "";
|
||||||
|
});
|
||||||
|
const difficultyText = computed(() => {
|
||||||
|
const difficultyLevel = Number(practiseDetail.value.difficultyLevel);
|
||||||
|
return Number.isInteger(difficultyLevel) && difficultyLevel > 0
|
||||||
|
? `LV${difficultyLevel}`
|
||||||
|
: "";
|
||||||
|
});
|
||||||
|
const totalRings = computed(() => {
|
||||||
|
const interpretationTotal = Number(
|
||||||
|
practiseDetail.value.interpretation?.totalRings
|
||||||
|
);
|
||||||
|
if (Number.isFinite(interpretationTotal)) return interpretationTotal;
|
||||||
|
return arrows.value.reduce(
|
||||||
|
(total, arrow) => total + (Number(arrow.ring) || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizeArrow = (arrow = {}) => ({
|
||||||
|
...arrow,
|
||||||
|
ring: Number(arrow.ring) || 0,
|
||||||
|
ringX:
|
||||||
|
arrow.ringX === true ||
|
||||||
|
Number(arrow.ringX) === 1 ||
|
||||||
|
Number(arrow.ifX) === 1,
|
||||||
|
ok: arrow.ok === true || Number(arrow.ok) === 1,
|
||||||
|
});
|
||||||
|
|
||||||
onLoad(async (options) => {
|
onLoad(async (options) => {
|
||||||
if (!options.id) return;
|
if (!options.id) return;
|
||||||
const result = await getPractiseAPI(options.id || 176);
|
const result = (await getPractiseDetailAPI(options.id)) || {};
|
||||||
arrows.value = result.details;
|
practiseDetail.value = result;
|
||||||
total.value = result.details.length;
|
arrows.value = Array.isArray(result.details)
|
||||||
|
? result.details.map(normalizeArrow)
|
||||||
|
: [];
|
||||||
|
isSvip.value = result.sVip === true || user.value?.sVip === true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -33,22 +71,29 @@ onLoad(async (options) => {
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view> -->
|
</view> -->
|
||||||
|
<view v-if="practiseDetail.id" class="practice-meta">
|
||||||
|
<text v-if="targetTypeText">{{ targetTypeText }}</text>
|
||||||
|
<text>{{ practiseDetail.trainingTypeText }}</text>
|
||||||
|
<text v-if="difficultyText">{{ difficultyText }}</text>
|
||||||
|
</view>
|
||||||
<view :style="{ marginBottom: '20px' }">
|
<view :style="{ marginBottom: '20px' }">
|
||||||
<BowTarget :scores="arrows" />
|
<BowTarget
|
||||||
|
:scores="arrows"
|
||||||
|
:isSvip="isSvip"
|
||||||
|
:enableShotEffect="false"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
<view class="desc">
|
<view class="desc">
|
||||||
<text>{{ arrows.length }}</text>
|
<text>{{ arrows.length }}</text>
|
||||||
<text>支箭,共</text>
|
<text>支箭,共</text>
|
||||||
<text>{{ arrows.reduce((a, b) => a + b.ring, 0) }}</text>
|
<text>{{ totalRings }}</text>
|
||||||
<text>环</text>
|
<text>环</text>
|
||||||
</view>
|
</view>
|
||||||
<ScorePanel
|
<ScorePanel2
|
||||||
:completeEffect="false"
|
|
||||||
:rowCount="total === 12 ? 6 : 9"
|
|
||||||
:total="total"
|
|
||||||
:arrows="arrows"
|
:arrows="arrows"
|
||||||
:margin="arrows.length === 12 ? 4 : 1"
|
:total="arrows.length"
|
||||||
:fontSize="arrows.length === 12 ? 25 : 22"
|
:trainingType="trainingType"
|
||||||
|
recordMode
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -61,6 +106,20 @@ onLoad(async (options) => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
.practice-meta {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 40rpx;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
}
|
||||||
|
.practice-meta > text + text {
|
||||||
|
margin-left: 24rpx;
|
||||||
|
}
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
+97
-29
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { onShow } from "@dcloudio/uni-app";
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import ScreenHint from "@/components/ScreenHint.vue";
|
import ScreenHint from "@/components/ScreenHint.vue";
|
||||||
@@ -16,11 +16,13 @@ const showTip = ref(false);
|
|||||||
const confirmBindTip = ref(false);
|
const confirmBindTip = ref(false);
|
||||||
const addDevice = ref();
|
const addDevice = ref();
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const { updateDevice } = store;
|
const { updateDevice, clearDevice } = store;
|
||||||
const { user, device } = storeToRefs(store);
|
const { user, device } = storeToRefs(store);
|
||||||
const justBind = ref(false);
|
const justBind = ref(false);
|
||||||
const calibration = ref(false);
|
const calibration = ref(false);
|
||||||
const token = ref(null);
|
const token = ref(null);
|
||||||
|
const isSVip = computed(() => user.value.sVip === true);
|
||||||
|
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||||
|
|
||||||
// 扫描二维码方法
|
// 扫描二维码方法
|
||||||
const handleScan = () => {
|
const handleScan = () => {
|
||||||
@@ -84,13 +86,26 @@ const toFristTryPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const unbindDevice = async () => {
|
const unbindDevice = async () => {
|
||||||
|
try {
|
||||||
await unbindDeviceAPI(device.value.deviceId);
|
await unbindDeviceAPI(device.value.deviceId);
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.type === "DEVICE_BIND_INVALID") {
|
||||||
|
uni.setStorageSync("calibration", false);
|
||||||
|
clearDevice();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
uni.setStorageSync("calibration", false);
|
uni.setStorageSync("calibration", false);
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: "解绑成功",
|
title: "解绑成功",
|
||||||
icon: "success",
|
icon: "success",
|
||||||
});
|
});
|
||||||
device.value = {};
|
clearDevice();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 连接wifi跳转到wifi列表页面 */
|
||||||
|
const joinWifi = () => {
|
||||||
|
uni.navigateTo({ url: "/pages/ota-wifi" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const toDeviceIntroPage = () => {
|
const toDeviceIntroPage = () => {
|
||||||
@@ -122,8 +137,23 @@ const goCalibration = async () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onShow(() => {
|
const syncDeviceBinding = async () => {
|
||||||
|
if (!user.value.id) return;
|
||||||
|
try {
|
||||||
|
const devices = await getMyDevicesAPI();
|
||||||
|
if (devices.bindings && devices.bindings.length) {
|
||||||
|
updateDevice(devices.bindings[0].deviceId, devices.bindings[0].deviceName);
|
||||||
|
} else {
|
||||||
|
clearDevice();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("sync device binding error", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
calibration.value = uni.getStorageSync("calibration");
|
calibration.value = uni.getStorageSync("calibration");
|
||||||
|
await syncDeviceBinding();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -131,7 +161,7 @@ onShow(() => {
|
|||||||
<Container title="弓箭绑定">
|
<Container title="弓箭绑定">
|
||||||
<view v-if="!device.deviceId" class="scan-code">
|
<view v-if="!device.deviceId" class="scan-code">
|
||||||
<button hover-class="none" @click="$clickSound(handleScan)">
|
<button hover-class="none" @click="$clickSound(handleScan)">
|
||||||
<image src="../static/scan.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/scan.png" mode="widthFix" />
|
||||||
</button>
|
</button>
|
||||||
<button hover-class="none" @click="showTip = true">
|
<button hover-class="none" @click="showTip = true">
|
||||||
<text>扫</text>
|
<text>扫</text>
|
||||||
@@ -142,7 +172,7 @@ onShow(() => {
|
|||||||
<text>射灵智能弓箭,三模传感系统与独创靶环算法,</text>
|
<text>射灵智能弓箭,三模传感系统与独创靶环算法,</text>
|
||||||
<text>毫秒级在线实时对战,让你拥有全球约战的乐趣!</text>
|
<text>毫秒级在线实时对战,让你拥有全球约战的乐趣!</text>
|
||||||
<button hover-class="none" @click="toDeviceIntroPage">
|
<button hover-class="none" @click="toDeviceIntroPage">
|
||||||
<image src="../static/have-no-device.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/have-no-device.png" mode="widthFix" />
|
||||||
</button>
|
</button>
|
||||||
<ScreenHint
|
<ScreenHint
|
||||||
mode="square"
|
mode="square"
|
||||||
@@ -189,7 +219,7 @@ onShow(() => {
|
|||||||
:style="{ marginBottom: calibration ? '250rpx' : '100rpx' }"
|
:style="{ marginBottom: calibration ? '250rpx' : '100rpx' }"
|
||||||
>
|
>
|
||||||
<view>
|
<view>
|
||||||
<image src="../static/device-icon.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/device-icon.png" mode="widthFix" />
|
||||||
<text>{{ device.deviceName }}</text>
|
<text>{{ device.deviceName }}</text>
|
||||||
<view class="calibration" v-if="calibration">
|
<view class="calibration" v-if="calibration">
|
||||||
<button hover-class="none" @click="goCalibration">
|
<button hover-class="none" @click="goCalibration">
|
||||||
@@ -209,14 +239,33 @@ onShow(() => {
|
|||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
:style="{ borderRadius: '50%' }"
|
:style="{ borderRadius: '50%' }"
|
||||||
/>
|
/>
|
||||||
<text>{{ user.nickName }}</text>
|
<view
|
||||||
</view>
|
:class="[
|
||||||
</view>
|
'member-nickname',
|
||||||
<block v-if="calibration">
|
isVip ? 'member-nickname--vip' : '',
|
||||||
<SButton :onClick="toFristTryPage" width="60vw" :rounded="40"
|
isSVip ? 'member-nickname--svip' : '',
|
||||||
>进入新手试炼</SButton
|
]"
|
||||||
>
|
>
|
||||||
<view :style="{ marginTop: '15px' }">
|
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||||
|
<text v-if="isSVip" class="member-nickname__shine">{{
|
||||||
|
user.nickName
|
||||||
|
}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- <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
|
<SButton
|
||||||
:onClick="backToHome"
|
:onClick="backToHome"
|
||||||
backgroundColor="#fff3"
|
backgroundColor="#fff3"
|
||||||
@@ -226,24 +275,18 @@ onShow(() => {
|
|||||||
>返回首页</SButton
|
>返回首页</SButton
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
<view :style="{ marginTop: '15px' }">
|
||||||
<block v-else>
|
<SButton :onClick="toFristTryPage" width="60vw" :rounded="40">进入新手试炼</SButton>
|
||||||
<view>
|
|
||||||
<text>恭喜,你的弓箭和账号已成功绑定!</text>
|
|
||||||
<text :style="{ color: '#fed847' }">已赠送6个月射灵世界会员</text>
|
|
||||||
</view>
|
</view>
|
||||||
<SButton :onClick="goCalibration" width="60vw" :rounded="40">
|
<!-- </block> -->
|
||||||
开启智能弓进行校准
|
<!-- <block v-else>
|
||||||
</SButton>
|
|
||||||
<text :style="{ marginTop: '20rpx', fontSize: '24rpx', color: '#fff9' }"
|
</block> -->
|
||||||
>校准时弓箭激光将开启,请勿直视激光</text
|
|
||||||
>
|
|
||||||
</block>
|
|
||||||
</view>
|
</view>
|
||||||
<view v-if="device.deviceId && !justBind" class="has-device">
|
<view v-if="device.deviceId && !justBind" class="has-device">
|
||||||
<view class="device-binded">
|
<view class="device-binded">
|
||||||
<view>
|
<view>
|
||||||
<image src="../static/device-icon.png" mode="widthFix" />
|
<image src="https://static.shelingxingqiu.com/shootmini/static/device-icon.png" mode="widthFix" />
|
||||||
<text>{{ device.deviceName }}</text>
|
<text>{{ device.deviceName }}</text>
|
||||||
<view class="calibration">
|
<view class="calibration">
|
||||||
<button hover-class="none" @click="goCalibration">
|
<button hover-class="none" @click="goCalibration">
|
||||||
@@ -265,7 +308,18 @@ onShow(() => {
|
|||||||
mode="widthFix"
|
mode="widthFix"
|
||||||
:style="{ borderRadius: '50%' }"
|
: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>
|
</view>
|
||||||
<view :style="{ marginTop: '240rpx' }">
|
<view :style="{ marginTop: '240rpx' }">
|
||||||
@@ -273,6 +327,11 @@ onShow(() => {
|
|||||||
>解绑</SButton
|
>解绑</SButton
|
||||||
>
|
>
|
||||||
</view>
|
</view>
|
||||||
|
<view :style="{ marginTop: '20rpx' }">
|
||||||
|
<SButton :onClick="() => $clickSound(joinWifi)" width="80vw" :rounded="40"
|
||||||
|
>设备连接WIFI</SButton
|
||||||
|
>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</Container>
|
</Container>
|
||||||
</template>
|
</template>
|
||||||
@@ -394,7 +453,7 @@ onShow(() => {
|
|||||||
width: 140rpx;
|
width: 140rpx;
|
||||||
height: 140rpx;
|
height: 140rpx;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.device-binded > view > text {
|
.device-binded > view > text {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
@@ -403,6 +462,15 @@ onShow(() => {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
text-align: center;
|
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 {
|
.device-binded > image {
|
||||||
width: 100rpx;
|
width: 100rpx;
|
||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
|
|||||||
+11
-3
@@ -16,6 +16,14 @@ const matchList = ref([]);
|
|||||||
const battleList = ref([]);
|
const battleList = ref([]);
|
||||||
const practiseList = ref([]);
|
const practiseList = ref([]);
|
||||||
|
|
||||||
|
const formatPractiseTime = (value) => {
|
||||||
|
const normalizedTime = String(value || "").trim();
|
||||||
|
const matchedTime = normalizedTime.match(
|
||||||
|
/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2}:\d{2})/
|
||||||
|
);
|
||||||
|
return matchedTime ? `${matchedTime[1]} ${matchedTime[2]}` : normalizedTime;
|
||||||
|
};
|
||||||
|
|
||||||
const toMatchDetail = (id) => {
|
const toMatchDetail = (id) => {
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
url: `/pages/match-detail?battleId=${id}`,
|
url: `/pages/match-detail?battleId=${id}`,
|
||||||
@@ -81,7 +89,7 @@ onLoad((options) => {
|
|||||||
<Container title="我的成长脚印" :scroll="false">
|
<Container title="我的成长脚印" :scroll="false">
|
||||||
<view class="tabs">
|
<view class="tabs">
|
||||||
<view
|
<view
|
||||||
v-for="(rankType, index) in ['排位赛', '好友约战', '个人练习']"
|
v-for="(rankType, index) in ['排位赛', '好友约战', '个人训练']"
|
||||||
:key="index"
|
:key="index"
|
||||||
:style="{
|
:style="{
|
||||||
color: index === selectedIndex ? '#000' : '#fff',
|
color: index === selectedIndex ? '#000' : '#fff',
|
||||||
@@ -153,8 +161,8 @@ onLoad((options) => {
|
|||||||
@click="() => getPractiseDetail(item.id)"
|
@click="() => getPractiseDetail(item.id)"
|
||||||
>
|
>
|
||||||
<text
|
<text
|
||||||
>{{ item.completed_arrows === 36 ? "耐力挑战" : "单组练习" }}
|
>{{ item.trainingTypeText }}
|
||||||
{{ item.createTime }}</text
|
{{ formatPractiseTime(item.createTime) }}</text
|
||||||
>
|
>
|
||||||
<image src="../static/back.png" mode="widthFix" />
|
<image src="../static/back.png" mode="widthFix" />
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import Signin from "@/components/Signin.vue";
|
||||||
|
import SButton from "@/components/SButton.vue";
|
||||||
|
import Avatar from "@/components/Avatar.vue";
|
||||||
|
import AppBackground from "@/components/AppBackground.vue";
|
||||||
|
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||||
|
import { tempBindOrgAPI } from "@/apis";
|
||||||
|
import useStore from "@/store";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
const { user } = storeToRefs(store);
|
||||||
|
|
||||||
|
const scene = ref("");
|
||||||
|
const status = ref("idle");
|
||||||
|
const errorMessage = ref("");
|
||||||
|
const showSignin = ref(false);
|
||||||
|
const binding = ref(false);
|
||||||
|
const bindInfo = ref({});
|
||||||
|
|
||||||
|
const successInfo = computed(() => {
|
||||||
|
const result = bindInfo.value || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatar: user.value.avatar,
|
||||||
|
nickName: user.value.nickName || "--",
|
||||||
|
mobile: result.mobile || "--",
|
||||||
|
storeName: result.storeName || "--",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const getToken = () => {
|
||||||
|
try {
|
||||||
|
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||||
|
return uni.getStorageSync(`${env}_token`);
|
||||||
|
} catch (error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSignin = () => {
|
||||||
|
showSignin.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSignin = () => {
|
||||||
|
showSignin.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindOrg = async () => {
|
||||||
|
if (binding.value || !scene.value) return;
|
||||||
|
|
||||||
|
if (!getToken()) {
|
||||||
|
status.value = "login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.value = true;
|
||||||
|
status.value = "binding";
|
||||||
|
errorMessage.value = "";
|
||||||
|
bindInfo.value = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = (await tempBindOrgAPI(scene.value)) || {};
|
||||||
|
if (result.success !== true) {
|
||||||
|
status.value = "failed";
|
||||||
|
errorMessage.value = result.msg || "授权登录失败,请稍后重试。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindInfo.value = result;
|
||||||
|
status.value = "success";
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.type === "AUTH_INVALID") {
|
||||||
|
status.value = "login";
|
||||||
|
errorMessage.value = "登录已失效,请重新登录后完成绑定。";
|
||||||
|
} else {
|
||||||
|
status.value = "failed";
|
||||||
|
errorMessage.value = error?.message || "网络异常,请稍后重试。";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
binding.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoginSuccess = async () => {
|
||||||
|
await bindOrg();
|
||||||
|
};
|
||||||
|
|
||||||
|
const goHome = () => {
|
||||||
|
uni.reLaunch({ url: "/pages/index" });
|
||||||
|
};
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
try {
|
||||||
|
const value = decodeURIComponent(String(options?.scene || ""));
|
||||||
|
if (!/^org_\d+$/.test(value)) {
|
||||||
|
status.value = "invalid";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.value = value;
|
||||||
|
if (getToken()) {
|
||||||
|
void bindOrg();
|
||||||
|
} else {
|
||||||
|
status.value = "login";
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
status.value = "invalid";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<AppBackground :type="6" bgColor="#20202c" />
|
||||||
|
|
||||||
|
<view class="content">
|
||||||
|
<text class="title">登录门店Pad端</text>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="status === 'idle' || status === 'binding'"
|
||||||
|
class="state message-state"
|
||||||
|
>
|
||||||
|
<text class="state-title">正在授权登录</text>
|
||||||
|
<text class="description">正在登录门店Pad端,请稍候……</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="status === 'login'" class="state login-state">
|
||||||
|
<image
|
||||||
|
class="login-icon"
|
||||||
|
src="../static/org-bind/login-icon.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<text class="login-title">{{
|
||||||
|
errorMessage || "请先登录小程序"
|
||||||
|
}}</text>
|
||||||
|
<SButton width="600rpx" :rounded="22" :onClick="openSignin">
|
||||||
|
<text>微信授权登录</text>
|
||||||
|
</SButton>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="status === 'success'" class="state success-state">
|
||||||
|
<view class="avatar-wrap">
|
||||||
|
<Avatar
|
||||||
|
:src="successInfo.avatar"
|
||||||
|
:size="176"
|
||||||
|
sizeUnit="rpx"
|
||||||
|
imageMode="aspectFill"
|
||||||
|
/>
|
||||||
|
<image
|
||||||
|
class="success-badge"
|
||||||
|
src="../static/org-bind/green-gou.png"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<text class="success-title">已成功授权登录射灵星球门店Pad端。</text>
|
||||||
|
<view class="account-info">
|
||||||
|
<text>登录账号:{{ successInfo.nickName }}</text>
|
||||||
|
<text>手机号:{{ successInfo.mobile }}</text>
|
||||||
|
<text>登录门店:{{ successInfo.storeName }}</text>
|
||||||
|
<text class="warm-tip">
|
||||||
|
温馨提示:离开门店时候记得在Pad退出登录哦!
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="success-action">
|
||||||
|
<SButton width="600rpx" :rounded="22" :onClick="goHome">
|
||||||
|
<text>回到首页</text>
|
||||||
|
</SButton>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="status === 'failed'" class="state message-state">
|
||||||
|
<text class="state-title">授权登录失败</text>
|
||||||
|
<text class="description">{{ errorMessage }}</text>
|
||||||
|
<SButton width="600rpx" :rounded="22" :onClick="bindOrg">
|
||||||
|
<text>重新登录</text>
|
||||||
|
</SButton>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="state message-state">
|
||||||
|
<text class="state-title">二维码无效</text>
|
||||||
|
<text class="description">请重新扫描门店Pad端上的小程序码。</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<Signin
|
||||||
|
:show="showSignin"
|
||||||
|
:onClose="closeSignin"
|
||||||
|
:onSuccess="handleLoginSuccess"
|
||||||
|
/>
|
||||||
|
<DeviceChargingDialog />
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: calc(var(--status-bar-height) + 64rpx) 48rpx 80rpx;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
color: #d8ad69;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-state,
|
||||||
|
.success-state {
|
||||||
|
margin-top: 320rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-state {
|
||||||
|
margin-top: 240rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-icon {
|
||||||
|
width: 176rpx;
|
||||||
|
height: 176rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
width: 600rpx;
|
||||||
|
margin: 28rpx 0 60rpx;
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: -2rpx;
|
||||||
|
bottom: 4rpx;
|
||||||
|
width: 40rpx;
|
||||||
|
height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-title {
|
||||||
|
width: 572rpx;
|
||||||
|
margin-top: 28rpx;
|
||||||
|
color: #fed847;
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-info {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 572rpx;
|
||||||
|
margin-top: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
color: #FFFFFF;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 52rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warm-tip {
|
||||||
|
margin-top: 40rpx;
|
||||||
|
color: #b8b8bd;
|
||||||
|
font-size: 24rpx;
|
||||||
|
line-height: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-action {
|
||||||
|
margin-top: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-title {
|
||||||
|
font-size: 38rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
max-width: 600rpx;
|
||||||
|
margin: 32rpx 0 64rpx;
|
||||||
|
color: #b8b8b8;
|
||||||
|
font-size: 28rpx;
|
||||||
|
line-height: 1.8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</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