Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75c7df0e9c |
@@ -8,12 +8,6 @@ pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.history
|
||||
.github
|
||||
.claude
|
||||
openspec
|
||||
CLAUDE.md
|
||||
docs
|
||||
.DS_Store
|
||||
dist
|
||||
*.local
|
||||
|
||||
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": [],
|
||||
"files.watcherExclude": {
|
||||
"**/dist/**": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/dist/**": true
|
||||
}
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
# AI Agent 企业级行为策略(Ultimate Edition)
|
||||
|
||||
## 核心目标
|
||||
|
||||
AI 应:
|
||||
|
||||
* 像高级工程师一样思考
|
||||
* 保持智能
|
||||
* 保持上下文理解能力
|
||||
* 保持组件联动能力
|
||||
* 同时避免无意义 token 消耗
|
||||
|
||||
目标不是限制 AI。
|
||||
|
||||
目标是:
|
||||
|
||||
* 智能
|
||||
* 克制
|
||||
* 稳定
|
||||
* 高效
|
||||
|
||||
---
|
||||
|
||||
# AI 工作模式
|
||||
|
||||
默认采用:
|
||||
|
||||
Think First
|
||||
Explore Second
|
||||
Modify Last
|
||||
|
||||
即:
|
||||
|
||||
1. 先理解需求
|
||||
2. 再推理可能相关文件
|
||||
3. 再最小化读取
|
||||
4. 最后修改代码
|
||||
|
||||
禁止:
|
||||
|
||||
* 无脑全项目扫描
|
||||
* 不经思考直接 grep
|
||||
* 无限递归读取
|
||||
|
||||
---
|
||||
|
||||
# 智能按需扫描(核心规则)
|
||||
|
||||
允许 AI 自动:
|
||||
|
||||
* 分析当前任务
|
||||
* 分析 import
|
||||
* 分析组件依赖
|
||||
* 分析 store 依赖
|
||||
* 分析 api 依赖
|
||||
* 分析 types 依赖
|
||||
* 分析 utils 依赖
|
||||
|
||||
允许:
|
||||
|
||||
* 自动读取直接依赖文件
|
||||
* 自动修复 import
|
||||
* 自动修复类型引用
|
||||
* 自动分析运行链路
|
||||
|
||||
但必须:
|
||||
|
||||
* 最小化扫描范围
|
||||
* 最小化 token 消耗
|
||||
* 禁止无限递归探索
|
||||
|
||||
---
|
||||
|
||||
# 扫描深度限制
|
||||
|
||||
默认最大依赖深度:
|
||||
|
||||
2 层
|
||||
|
||||
例如:
|
||||
|
||||
index.vue
|
||||
-> ProductCard.vue
|
||||
-> product.ts
|
||||
|
||||
允许读取:
|
||||
|
||||
* ProductCard.vue
|
||||
* product.ts
|
||||
|
||||
禁止继续无限扫描。
|
||||
|
||||
如果任务复杂:
|
||||
|
||||
必须先输出分析计划,
|
||||
等待确认后再扩大扫描范围。
|
||||
|
||||
---
|
||||
|
||||
# AI 自由发挥边界
|
||||
|
||||
允许:
|
||||
|
||||
* 合理重构
|
||||
* 合理组件化
|
||||
* 合理优化结构
|
||||
* 合理优化样式
|
||||
* 合理优化复用
|
||||
* 合理修复低级问题
|
||||
* 合理修复 import
|
||||
* 合理修复类型错误
|
||||
|
||||
禁止:
|
||||
|
||||
* 为了炫技重构项目
|
||||
* 无意义抽象
|
||||
* 过度设计
|
||||
* 无意义拆分
|
||||
* 无意义新增依赖
|
||||
* 自动升级依赖
|
||||
|
||||
---
|
||||
|
||||
# Token 经济策略
|
||||
|
||||
Token 应优先用于:
|
||||
|
||||
* 推理
|
||||
* 架构理解
|
||||
* 业务逻辑
|
||||
* UI 结构优化
|
||||
* 类型安全
|
||||
* 组件联动
|
||||
|
||||
禁止浪费在:
|
||||
|
||||
* 全项目 grep
|
||||
* 重复读取
|
||||
* 重复输出
|
||||
* 重复解释
|
||||
* 输出完整项目
|
||||
* 输出未修改代码
|
||||
|
||||
---
|
||||
|
||||
# 页面生成规则(Figma / uni-app)
|
||||
|
||||
允许:
|
||||
|
||||
* 自动组件化
|
||||
* 自动布局优化
|
||||
* 自动结构优化
|
||||
* 自动提取公共组件
|
||||
|
||||
优先:
|
||||
|
||||
* flex 布局
|
||||
* 可维护性
|
||||
* uni-app 最佳实践
|
||||
* 低嵌套结构
|
||||
* 高复用结构
|
||||
|
||||
禁止:
|
||||
|
||||
* div 套 div
|
||||
* 全 absolute 页面
|
||||
* 垃圾 HTML
|
||||
* 无意义嵌套
|
||||
* 内联 style 泛滥
|
||||
|
||||
---
|
||||
|
||||
# uni-app 规则
|
||||
|
||||
必须:
|
||||
|
||||
* 使用 view/text/image
|
||||
* px 转 rpx
|
||||
* 使用 script setup
|
||||
* scoped scss
|
||||
* 兼容:
|
||||
|
||||
* H5
|
||||
* 微信小程序
|
||||
* App
|
||||
|
||||
---
|
||||
|
||||
# 大任务策略
|
||||
|
||||
复杂任务:
|
||||
|
||||
必须:
|
||||
|
||||
1. 先分析
|
||||
2. 先规划
|
||||
3. 先输出方案
|
||||
4. 等待确认
|
||||
|
||||
再:
|
||||
|
||||
5. 编码
|
||||
|
||||
禁止直接进入大规模代码生成。
|
||||
|
||||
---
|
||||
|
||||
# 修改策略
|
||||
|
||||
优先:
|
||||
|
||||
* diff 修改
|
||||
* 小范围 patch
|
||||
* 保持现有架构
|
||||
* 保持现有组件体系
|
||||
* 保持现有 API 结构
|
||||
|
||||
允许:
|
||||
|
||||
* 小范围智能优化
|
||||
|
||||
禁止:
|
||||
|
||||
* 全项目重构
|
||||
* 无关文件修改
|
||||
|
||||
---
|
||||
|
||||
# 高级工程师行为模式
|
||||
|
||||
AI 应像高级工程师:
|
||||
|
||||
* 先思考
|
||||
* 再探索
|
||||
* 再修改
|
||||
|
||||
而不是:
|
||||
|
||||
* 无脑扫描器
|
||||
* Token 消耗机器
|
||||
* 低级代码生成器
|
||||
|
||||
AI 应主动:
|
||||
|
||||
* 控制扫描范围
|
||||
* 控制输出长度
|
||||
* 控制修改范围
|
||||
* 控制复杂度
|
||||
|
||||
同时保持:
|
||||
|
||||
* 智能
|
||||
* 联动能力
|
||||
* 架构理解能力
|
||||
|
||||
---
|
||||
|
||||
# 默认输出规则
|
||||
|
||||
默认:
|
||||
|
||||
* 仅输出修改部分
|
||||
* 不重复未修改代码
|
||||
* 少解释
|
||||
* 优先 patch
|
||||
* 优先 diff
|
||||
* 写好中文注释
|
||||
|
||||
除非用户明确要求:
|
||||
否则不要输出完整项目。
|
||||
@@ -1,246 +0,0 @@
|
||||
# 微信小程序多人协作分支管理规范
|
||||
|
||||
## 一、分支结构
|
||||
|
||||
```
|
||||
main (主分支/生产环境)
|
||||
└── test (测试分支)
|
||||
└── feature/xxx (个人开发分支)
|
||||
```
|
||||
|
||||
| 分支 | 用途 | 稳定性 |
|
||||
|------|------|--------|
|
||||
| main | 生产环境代码 | 最高,仅接受测试通过的代码合并 |
|
||||
| test | 测试环境,用于体验版发布 | 中,需验证后合并到 main |
|
||||
| feature/xxx | 个人开发分支 | 低,按需命名,如 `feature/user-center` |
|
||||
|
||||
---
|
||||
|
||||
## 二、开发流程
|
||||
|
||||
### 1. 开始开发
|
||||
|
||||
```bash
|
||||
# 确保本地 main 最新
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
# 从 main 创建自己的开发分支
|
||||
git checkout -b feature/your-name-work
|
||||
```
|
||||
|
||||
### 2. 开发阶段
|
||||
|
||||
- 在个人分支上开发功能
|
||||
- 频繁提交,保持原子性提交
|
||||
- 定期 `git pull origin main` 同步主线变更,避免合并冲突累积
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: 完成xxx功能"
|
||||
```
|
||||
|
||||
### 3. 合并到 test 分支
|
||||
|
||||
```bash
|
||||
# 切换到 test
|
||||
git checkout test
|
||||
git pull origin test
|
||||
|
||||
# 合并个人分支
|
||||
git merge feature/your-name-work
|
||||
|
||||
# 推送 test 分支
|
||||
git push origin test
|
||||
```
|
||||
|
||||
### 4. 打包上传体验版
|
||||
|
||||
```bash
|
||||
# 执行打包
|
||||
npm run build
|
||||
```
|
||||
|
||||
打包完成后:
|
||||
|
||||
1. 打开 **微信开发者工具**
|
||||
2. 导入项目,选择 `dist/build/mp-weixin` 目录
|
||||
3. 在开发者工具中点击 **上传**
|
||||
4. 登录 [微信公众平台](https://mp.weixin.qq.com)
|
||||
5. 进入 **管理->版本管理**
|
||||
6. 找到刚上传的版本,点击 **选为体验版**
|
||||
|
||||
---
|
||||
|
||||
## 三、合并到 main 分支
|
||||
|
||||
当 test 分支验证通过后,将其合并到 main:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
git merge origin/test
|
||||
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、冲突处理
|
||||
|
||||
合并时如有冲突,在个人分支解决后再合并:
|
||||
|
||||
```bash
|
||||
git checkout feature/your-name-work
|
||||
git merge main
|
||||
# 解决冲突后
|
||||
git add .
|
||||
git commit -m "merge: 解决与main的冲突"
|
||||
git push origin feature/your-name-work
|
||||
|
||||
# 重新合并到 test
|
||||
git checkout test
|
||||
git merge feature/your-name-work
|
||||
git push origin test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
1. **禁止直接向 main 和 test 分支提交代码**,必须通过合并
|
||||
2. **每次合并前先拉取最新代码**,避免覆盖他人改动
|
||||
3. **体验版发布前确认代码已提交**,避免遗漏
|
||||
4. **开发分支命名建议**:`feature/姓名-功能名`,如 `feature/zhangsan-login`
|
||||
5. **删除已合并的开发分支**:`git branch -d feature/your-name-work`
|
||||
|
||||
---
|
||||
|
||||
## 六、比赛服 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. 微信开发者工具中的主包、分包体积符合上传限制。
|
||||
+1
-2
@@ -12,6 +12,5 @@
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"plugins": ["@uni-helper/uni-types/volar-plugin"]
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-628
@@ -26,8 +26,6 @@
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-ui": "^1.5.11",
|
||||
"pinia": "2.0.36",
|
||||
"pinia-plugin-persistedstate": "3.2.1",
|
||||
"protobufjs": "^8.7.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
@@ -2424,70 +2422,6 @@
|
||||
"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": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz",
|
||||
@@ -2504,294 +2438,6 @@
|
||||
"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": {
|
||||
"version": "9.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.1.9.tgz",
|
||||
@@ -3778,32 +3424,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.39.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz",
|
||||
@@ -3817,227 +3437,6 @@
|
||||
"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": {
|
||||
"version": "1.8.6",
|
||||
"resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz",
|
||||
@@ -8136,12 +7535,6 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -8829,15 +8222,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||
@@ -9146,18 +8530,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": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"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",
|
||||
"prebuild": "npm run proto:check",
|
||||
"build": "uni build -p mp-weixin"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -30,8 +25,6 @@
|
||||
"@dcloudio/uni-quickapp-webview": "3.0.0-4060620250520001",
|
||||
"@dcloudio/uni-ui": "^1.5.11",
|
||||
"pinia": "2.0.36",
|
||||
"pinia-plugin-persistedstate": "3.2.1",
|
||||
"protobufjs": "^8.7.0",
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.1.9"
|
||||
},
|
||||
|
||||
+1
-242
@@ -3,248 +3,7 @@
|
||||
"compileType": "miniprogram",
|
||||
"libVersion": "3.7.7",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"setting": {
|
||||
|
||||
@@ -1,441 +0,0 @@
|
||||
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;
|
||||
}
|
||||
+19
-210
@@ -1,36 +1,13 @@
|
||||
<script setup>
|
||||
import {
|
||||
watch
|
||||
} from "vue";
|
||||
import {
|
||||
onShow,
|
||||
onHide
|
||||
} from "@dcloudio/uni-app";
|
||||
import { watch } from "vue";
|
||||
import { onShow, onHide } from "@dcloudio/uni-app";
|
||||
import websocket from "@/websocket";
|
||||
import matchWebsocket from "@/matchWebsocket";
|
||||
import {
|
||||
MESSAGETYPES
|
||||
} from "@/constants";
|
||||
import { getDeviceBatteryAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import {
|
||||
storeToRefs
|
||||
} from "pinia";
|
||||
import audioManager from "./audioManager";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const {
|
||||
user,
|
||||
device,
|
||||
online
|
||||
} = storeToRefs(store);
|
||||
const {
|
||||
updateUser,
|
||||
updateDeviceStatus,
|
||||
setDeviceOnline,
|
||||
clearDeviceStatus,
|
||||
showDeviceChargingDialog,
|
||||
clearSessionState,
|
||||
clearDevice
|
||||
} = store;
|
||||
const { user } = storeToRefs(store);
|
||||
const { updateUser, updateOnline } = store;
|
||||
|
||||
watch(
|
||||
() => user.value.id,
|
||||
@@ -39,15 +16,15 @@
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
if (newVal && token) {
|
||||
websocket.createWebSocket(token, onShootWsMsg);
|
||||
websocket.createWebSocket(token, (content) => {
|
||||
uni.$emit("socket-inbox", content);
|
||||
});
|
||||
}
|
||||
if (!newVal) {
|
||||
matchWebsocket.closeMatchWebSocket({
|
||||
reason: "logout"
|
||||
});
|
||||
websocket.closeWebSocket();
|
||||
}
|
||||
}, {
|
||||
},
|
||||
{
|
||||
deep: false, // 如果 user 是一个对象或数组,建议开启
|
||||
immediate: false, // 若想在初始化时立即执行一次回调,可开启。
|
||||
}
|
||||
@@ -57,118 +34,28 @@
|
||||
updateUser(value);
|
||||
}
|
||||
|
||||
function onSessionKickedOut() {
|
||||
const env = uni.getAccountInfoSync().miniProgram.envVersion;
|
||||
uni.removeStorageSync(`${env}_token`);
|
||||
clearSessionState();
|
||||
uni.showModal({
|
||||
title: "提示",
|
||||
content: "账号已在其他设备登录",
|
||||
showCancel: false,
|
||||
});
|
||||
}
|
||||
|
||||
function emitUpdateOnline(nextOnline) {
|
||||
const wasOnline = Boolean(online.value);
|
||||
setDeviceOnline(nextOnline === true);
|
||||
if (!device.value.deviceId || wasOnline === (nextOnline === true)) return;
|
||||
audioManager.play(nextOnline === true ? "设备已连接" : "设备连接已断开");
|
||||
}
|
||||
|
||||
function onDeviceStatusPush(status) {
|
||||
updateDeviceStatus(status);
|
||||
}
|
||||
|
||||
function onShootSocketDisconnected() {
|
||||
clearDeviceStatus();
|
||||
}
|
||||
|
||||
function onDeviceBindInvalid() {
|
||||
clearDevice();
|
||||
uni.setStorageSync("calibration", false);
|
||||
}
|
||||
|
||||
function onDeviceCharging() {
|
||||
showDeviceChargingDialog();
|
||||
}
|
||||
|
||||
function onDeviceShoot() {
|
||||
// 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) {
|
||||
if (content?.event === "/addons/shoot/battery") {
|
||||
onDeviceStatusPush(content.data);
|
||||
return;
|
||||
}
|
||||
if(content.type === 'shoot-trigger'){
|
||||
onDeviceShoot()
|
||||
}
|
||||
connectMatchServerFromMessage(content);
|
||||
uni.$emit("socket-inbox", content);
|
||||
async function emitUpdateOnline() {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
updateOnline(data.online);
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
void audioManager.warmButton();
|
||||
uni.$on("update-user", emitUpdateUser);
|
||||
uni.$on("update-online", emitUpdateOnline);
|
||||
uni.$on("shoot-socket-disconnected", onShootSocketDisconnected);
|
||||
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(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
if (user.value.id && token) {
|
||||
console.log("回到前台,重新连接 websocket");
|
||||
websocket.createWebSocket(token, onShootWsMsg);
|
||||
websocket.createWebSocket(token, (content) => {
|
||||
uni.$emit("socket-inbox", content);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
uni.$off("update-user", emitUpdateUser);
|
||||
uni.$off("update-online", emitUpdateOnline);
|
||||
uni.$off("shoot-socket-disconnected", onShootSocketDisconnected);
|
||||
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"
|
||||
});
|
||||
clearDeviceStatus();
|
||||
websocket.closeWebSocket();
|
||||
});
|
||||
</script>
|
||||
@@ -206,11 +93,9 @@
|
||||
flex-direction: column;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.guide-tips > text:first-child {
|
||||
color: #fed847;
|
||||
}
|
||||
|
||||
.guide-tips > text:nth-child(2) {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
@@ -220,16 +105,13 @@
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
80% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -244,7 +126,6 @@
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
@@ -260,7 +141,6 @@
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
@@ -277,7 +157,6 @@
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
@@ -293,7 +172,6 @@
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -303,7 +181,6 @@
|
||||
from {
|
||||
transform: scale(2);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
@@ -328,69 +205,6 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-nickname {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member-nickname__text,
|
||||
.member-nickname__shine {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-nickname--vip .member-nickname__text {
|
||||
color: #E7BA80;
|
||||
}
|
||||
|
||||
.member-nickname--svip .member-nickname__text {
|
||||
background: linear-gradient(90deg, #ffb86c, #ff4fd8, #7c5cff, #35d6ff);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.member-nickname__shine {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
110deg,
|
||||
transparent 0%,
|
||||
transparent 38%,
|
||||
rgba(255, 255, 255, 0.15) 45%,
|
||||
rgba(255, 255, 255, 1) 50%,
|
||||
rgba(255, 255, 255, 0.15) 55%,
|
||||
transparent 62%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
background-position: 120% 0;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
animation: memberNicknameShine 3.5s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes memberNicknameShine {
|
||||
0%,
|
||||
50% {
|
||||
background-position: 120% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
@@ -398,7 +212,6 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -407,7 +220,6 @@
|
||||
padding-top: 7px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.half-time-tip {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -415,31 +227,28 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.half-time-tip > text:last-child {
|
||||
margin-top: 20px;
|
||||
color: #fff9;
|
||||
}
|
||||
|
||||
.see-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
|
||||
.see-more > text {
|
||||
color: #39a8ff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.see-more > image {
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "DINCondensed";
|
||||
src: url("https://static.shelingxingqiu.com/font/DIN-Condensed-Bold-2.ttf") format("truetype");
|
||||
src: url("https://static.shelingxingqiu.com/font/DIN-Condensed-Bold-2.ttf")
|
||||
format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
+129
-355
@@ -1,5 +1,3 @@
|
||||
import { normalizeBattleApiResult } from "@/utils/matchAdapter";
|
||||
|
||||
let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版
|
||||
|
||||
try {
|
||||
@@ -8,7 +6,7 @@ try {
|
||||
|
||||
switch (envVersion) {
|
||||
case "develop": // 开发版
|
||||
// BASE_URL = "http://192.168.1.30:8000/api/shoot";
|
||||
// BASE_URL = "http://192.168.1.242:8000/api/shoot";
|
||||
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
|
||||
break;
|
||||
case "trial": // 体验版
|
||||
@@ -25,10 +23,7 @@ try {
|
||||
console.error("获取环境信息失败,使用默认正式环境", e);
|
||||
}
|
||||
|
||||
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]) {
|
||||
function request(method, url, data = {}) {
|
||||
const token = uni.getStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
@@ -36,37 +31,21 @@ function request(method, url, data = {}, baseUrl = BASE_URL, successCodes = [0])
|
||||
if (token) header.Authorization = `Bearer ${token || ""}`;
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
url: `${baseUrl}${url}`,
|
||||
url: `${BASE_URL}${url}`,
|
||||
method,
|
||||
header,
|
||||
data,
|
||||
timeout: 10000,
|
||||
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) {
|
||||
const { code, data, message } = res.data;
|
||||
if (successCodes.includes(code)) resolve(data);
|
||||
if (code === 0) resolve(data);
|
||||
else if (message) {
|
||||
const error = {code, data, message};
|
||||
if (message.indexOf("登录身份已失效") !== -1) {
|
||||
console.log('1111111111111111111,token失效')
|
||||
uni.removeStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`
|
||||
);
|
||||
uni.$emit("update-user");
|
||||
reject({ type: "AUTH_INVALID", message });
|
||||
return;
|
||||
}
|
||||
if (message.indexOf("已达上限") !== -1) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
if (message === "ROOM_FULL") {
|
||||
resolve({ full: true });
|
||||
@@ -88,15 +67,6 @@ function request(method, url, data = {}, baseUrl = BASE_URL, successCodes = [0])
|
||||
resolve({ binded: true });
|
||||
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") {
|
||||
uni.showToast({
|
||||
title: "当前有未支付订单",
|
||||
@@ -115,10 +85,8 @@ function request(method, url, data = {}, baseUrl = BASE_URL, successCodes = [0])
|
||||
title: message,
|
||||
icon: "none",
|
||||
});
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
reject({code, data, message});
|
||||
reject("");
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
@@ -187,23 +155,8 @@ export const getAppConfig = () => {
|
||||
return request("GET", "/index/appConfig");
|
||||
};
|
||||
|
||||
export const getDailyCountAPI = () => {
|
||||
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 getHomeData = (seasonId) => {
|
||||
return request("GET", `/user/myHome?seasonId=${seasonId}`);
|
||||
};
|
||||
|
||||
export const getProvinceData = () => {
|
||||
@@ -226,112 +179,41 @@ export const loginAPI = async (phone, nickName, avatarData, code) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const silentLoginAPI = async (code) => {
|
||||
const result = await request("POST", "/index/code", {
|
||||
appName: "shoot",
|
||||
appId: "wxa8f5989dcd45cc23",
|
||||
code,
|
||||
});
|
||||
uni.setStorageSync(
|
||||
`${uni.getAccountInfoSync().miniProgram.envVersion}_token`,
|
||||
result.token
|
||||
);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const checkUserBindAPI = async (code) => {
|
||||
return request("POST", "/index/checkBind", {
|
||||
appName: "shoot",
|
||||
appId: "wxa8f5989dcd45cc23",
|
||||
code,
|
||||
});
|
||||
};
|
||||
|
||||
export const tempBindOrgAPI = (scene) => {
|
||||
return request("POST", "/user/org/device/tempBind", {
|
||||
scene,
|
||||
});
|
||||
};
|
||||
|
||||
export const bindDeviceAPI = (device) => {
|
||||
return request("POST", "/user/device/bindDevice", {
|
||||
device,
|
||||
});
|
||||
};
|
||||
|
||||
export const bindDeviceAPIV2 = (token) => {
|
||||
return request("POST", "/user/device/bindDevice/v2", {
|
||||
token: token,
|
||||
});
|
||||
};
|
||||
|
||||
export const unbindDeviceAPI = (deviceId) => {
|
||||
return request("POST", "/user/device/unbindDevice", {
|
||||
deviceId,
|
||||
});
|
||||
};
|
||||
|
||||
// 测试环境根据设备二维码编号解绑设备,不校验设备归属。
|
||||
export const unbindDeviceByQrcodeIdAPI = (id) => {
|
||||
return request("POST", "/user/device/unbindByQrcodeId", {id});
|
||||
};
|
||||
|
||||
export const getMyDevicesAPI = () => {
|
||||
// "/user/device/getBinding?deviceId=9ZF9oVXs"
|
||||
return request("GET", "/user/device/getBindings");
|
||||
};
|
||||
|
||||
export const getDeviceDetailAPI = (deviceId) => {
|
||||
return request("GET", `/user/device/getDetail?deviceId=${encodeURIComponent(deviceId)}`);
|
||||
};
|
||||
|
||||
export const updateDeviceAliasAPI = (deviceId, alias) => {
|
||||
return request("POST", "/user/device/updateAlias", {deviceId, alias});
|
||||
};
|
||||
|
||||
export const createPractiseAPI = (arrows, time, target) => {
|
||||
export const createPractiseAPI = (arrows, mode) => {
|
||||
return request("POST", "/user/practice/create", {
|
||||
shootNumber: arrows,
|
||||
shootTime: time,
|
||||
targetType: target * 20,
|
||||
arrows,
|
||||
mode,
|
||||
});
|
||||
};
|
||||
|
||||
export const createPractiseV2API = (trainingType, difficultyLevel) => {
|
||||
return request("POST", "/user/practice/create/v2", {
|
||||
trainingType,
|
||||
difficultyLevel,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCurrentPractiseAPI = () => {
|
||||
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) => {
|
||||
return request("GET", `/user/practice/get?id=${id}`);
|
||||
const result = await request("GET", `/user/practice/get?id=${id}`);
|
||||
const data = { ...(result.UserPracticeRound || {}) };
|
||||
if (data.arrows) data.arrows = JSON.parse(data.arrows);
|
||||
return data;
|
||||
};
|
||||
|
||||
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) => {
|
||||
return request("POST", "/user/createroom", {
|
||||
gameType,
|
||||
teamSize,
|
||||
targetType,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -360,12 +242,12 @@ export const startRoomAPI = (number) => {
|
||||
return request("POST", "/user/room/start", { number });
|
||||
};
|
||||
|
||||
export const getPractiseResultListAPI = async (page = 1, pageSize = 15) => {
|
||||
const result = await request(
|
||||
export const getPractiseResultListAPI = async (page = 1, page_size = 15) => {
|
||||
const reuslt = await request(
|
||||
"GET",
|
||||
`/user/practice/mylist?page=${page}&pageSize=${pageSize}&status=0`
|
||||
`/user/practice/list?page=${page}&page_size=${page_size}`
|
||||
);
|
||||
return Array.isArray(result?.list) ? result.list : [];
|
||||
return reuslt.list;
|
||||
};
|
||||
|
||||
export const matchGameAPI = (match, gameType, teamSize) => {
|
||||
@@ -373,8 +255,6 @@ export const matchGameAPI = (match, gameType, teamSize) => {
|
||||
match,
|
||||
gameType,
|
||||
teamSize,
|
||||
readyTime: 15,
|
||||
targetType: 20,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -384,11 +264,81 @@ export const readyGameAPI = (battleId) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const simulShootAPI = (device_id, x, y, targetType = 40) => {
|
||||
export const getGameAPI = async (battleId) => {
|
||||
const result = await request("POST", "/user/battle/detail", {
|
||||
id: battleId,
|
||||
});
|
||||
if (!result.battleStats) return {};
|
||||
const {
|
||||
battleStats = {},
|
||||
playerStats = {},
|
||||
goldenRoundRecords = [],
|
||||
} = result;
|
||||
const data = {
|
||||
id: battleId,
|
||||
mode: battleStats.mode, // 1.几V几 2.大乱斗
|
||||
gameMode: battleStats.gameMode, // 1.约战 2.排位
|
||||
teamSize: battleStats.teamSize,
|
||||
};
|
||||
if (battleStats && battleStats.mode === 1) {
|
||||
data.winner = battleStats.winner;
|
||||
data.roundsData = {};
|
||||
data.redPlayers = {};
|
||||
data.bluePlayers = {};
|
||||
data.mvps = [];
|
||||
data.goldenRounds =
|
||||
goldenRoundRecords && goldenRoundRecords.length ? goldenRoundRecords : [];
|
||||
playerStats.forEach((item) => {
|
||||
const { playerBattleStats = {}, roundRecords = [] } = item;
|
||||
if (playerBattleStats.team === 0) {
|
||||
data.redPlayers[playerBattleStats.playerId] = playerBattleStats;
|
||||
}
|
||||
if (playerBattleStats.team === 1) {
|
||||
data.bluePlayers[playerBattleStats.playerId] = playerBattleStats;
|
||||
}
|
||||
if (playerBattleStats.mvp) {
|
||||
data.mvps.push(playerBattleStats);
|
||||
}
|
||||
roundRecords.forEach((round) => {
|
||||
data.roundsData[round.roundNumber] = {
|
||||
...data.roundsData[round.roundNumber],
|
||||
[round.playerId]: round.arrowHistory,
|
||||
};
|
||||
});
|
||||
});
|
||||
const totalRounds = Object.keys(data.roundsData).length;
|
||||
(goldenRoundRecords || []).forEach((item, index) => {
|
||||
item.arrowHistory.forEach((arrow) => {
|
||||
if (!data.roundsData[totalRounds + index + 1]) {
|
||||
data.roundsData[totalRounds + index + 1] = {};
|
||||
}
|
||||
if (!data.roundsData[totalRounds + index + 1][arrow.playerId]) {
|
||||
data.roundsData[totalRounds + index + 1][arrow.playerId] = [];
|
||||
}
|
||||
data.roundsData[totalRounds + index + 1][arrow.playerId].push(arrow);
|
||||
});
|
||||
});
|
||||
|
||||
data.mvps.sort((a, b) => b.totalRings - a.totalRings);
|
||||
}
|
||||
if (battleStats && battleStats.mode === 2) {
|
||||
data.players = [];
|
||||
playerStats.forEach((item) => {
|
||||
data.players.push({
|
||||
...item.playerBattleStats,
|
||||
arrowHistory: item.roundRecords[0].arrowHistory,
|
||||
});
|
||||
});
|
||||
data.players = data.players.sort((a, b) => b.totalScore - a.totalScore);
|
||||
}
|
||||
// console.log("game result:", result);
|
||||
// console.log("format data:", data);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const simulShootAPI = (device_id, x, y) => {
|
||||
const data = {
|
||||
device_id,
|
||||
// 模拟射箭仅支持 20cm、40cm 靶纸,未传或传入无效值时默认使用 40cm。
|
||||
targetType: Number(targetType) === 20 ? 20 : 40,
|
||||
};
|
||||
if (x !== undefined && y !== undefined) {
|
||||
data.x = x;
|
||||
@@ -398,12 +348,39 @@ export const simulShootAPI = (device_id, x, y, targetType = 40) => {
|
||||
};
|
||||
|
||||
export const getBattleListAPI = async (page, battleType) => {
|
||||
const data = [];
|
||||
const result = await request("POST", "/user/battle/details/list", {
|
||||
page,
|
||||
pageSize: 10,
|
||||
battleType,
|
||||
modeType: 0,
|
||||
});
|
||||
return result.list;
|
||||
(result.Battles || []).forEach((item) => {
|
||||
let name = "";
|
||||
if (item.battleStats.mode === 1) {
|
||||
name = `${item.playerStats.length / 2}V${item.playerStats.length / 2}`;
|
||||
}
|
||||
if (item.battleStats.mode === 2) {
|
||||
name = `${item.playerStats.length}人大乱斗`;
|
||||
}
|
||||
data.push({
|
||||
name,
|
||||
battleId: item.battleStats.battleId,
|
||||
mode: item.battleStats.mode,
|
||||
createdAt: item.battleStats.createdAt,
|
||||
gameEndAt: item.battleStats.gameEndAt,
|
||||
winner: item.battleStats.winner,
|
||||
players: item.playerStats
|
||||
.map((p) => p.playerBattleStats)
|
||||
.sort((a, b) => b.totalScore - a.totalScore),
|
||||
redPlayers: item.playerStats
|
||||
.filter((p) => p.playerBattleStats.team === 0)
|
||||
.map((p) => p.playerBattleStats),
|
||||
bluePlayers: item.playerStats
|
||||
.filter((p) => p.playerBattleStats.team === 1)
|
||||
.map((p) => p.playerBattleStats),
|
||||
});
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getRankListAPI = () => {
|
||||
@@ -416,23 +393,9 @@ export const createOrderAPI = (vipId) => {
|
||||
quanity: 1,
|
||||
tradeType: "mini",
|
||||
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) => {
|
||||
return request("POST", "/user/order/pay", {
|
||||
id,
|
||||
@@ -454,6 +417,12 @@ export const getUserGameState = () => {
|
||||
return request("GET", "/user/state");
|
||||
};
|
||||
|
||||
export const getCurrentGameAPI = async () => {
|
||||
uni.$emit("update-header-loading", true);
|
||||
const result = await request("GET", "/user/join/battle");
|
||||
return result.currentGame || {};
|
||||
};
|
||||
|
||||
export const getPointBookConfigAPI = async () => {
|
||||
return request("GET", "/user/score/sheet/option");
|
||||
};
|
||||
@@ -507,15 +476,6 @@ export const getPractiseDataAPI = async () => {
|
||||
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 () => {
|
||||
return request("GET", "/user/fight/statistics");
|
||||
};
|
||||
@@ -546,47 +506,12 @@ export const laserAimAPI = async () => {
|
||||
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 () => {
|
||||
return request("POST", "/user/device/closeAim");
|
||||
};
|
||||
|
||||
// 设备连接指定 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 getDeviceBatteryAPI = async () => {
|
||||
return request("GET", "/user/device/battery");
|
||||
};
|
||||
|
||||
export const addNoteAPI = async (id, remark) => {
|
||||
@@ -601,10 +526,6 @@ export const getPhoneNumberAPI = (data) => {
|
||||
return request("POST", "/index/getPhone", data);
|
||||
};
|
||||
|
||||
export const getPhoneNumberAPIv2 = (data) => {
|
||||
return request("POST", "/index/getPhone/v2", data);
|
||||
};
|
||||
|
||||
export const getPointBookRankListAPI = (page = 1) => {
|
||||
return request(
|
||||
"GET",
|
||||
@@ -631,150 +552,3 @@ export const getReadyAPI = (roomId) => {
|
||||
roomId,
|
||||
});
|
||||
};
|
||||
|
||||
export const getBattleAPI = async (battleId) => {
|
||||
const result = await request("POST", "/user/match/info", {
|
||||
id: battleId,
|
||||
});
|
||||
return normalizeBattleApiResult(result);
|
||||
};
|
||||
|
||||
export const kickPlayerAPI = (number, userId) => {
|
||||
return request("POST", "/user/room/kicking", {
|
||||
number,
|
||||
userId,
|
||||
});
|
||||
};
|
||||
|
||||
// 获取赛季列表
|
||||
export const getSeasonList = () => {
|
||||
return request("GET", "/index/season/list");
|
||||
};
|
||||
|
||||
// 获取赛季统计
|
||||
export const getSeasonStats = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/season/stats", data);
|
||||
};
|
||||
|
||||
//获取积分榜
|
||||
export const getScoreRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/score/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取10环排行榜
|
||||
export const getTenRingRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/tenRing/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取MVP排行榜
|
||||
export const getMvpRankList = (seasonId, page, perPage) => {
|
||||
return request("GET", "/index/mvp/rank/list", {
|
||||
seasonId,
|
||||
page,
|
||||
perPage
|
||||
});
|
||||
};
|
||||
|
||||
// 获取我的积分排名
|
||||
export const getMyScoreRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myScoreRank", data);
|
||||
};
|
||||
|
||||
// 获取我的MVP排名
|
||||
export const getMyMvpRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myMvpRank", data);
|
||||
};
|
||||
|
||||
// 获取我的10环排名
|
||||
export const getMyTenRingRank = (seasonId) => {
|
||||
const data = {};
|
||||
if (seasonId !== undefined && seasonId !== null) data.seasonId = seasonId;
|
||||
return request("GET", "/index/myTenRingRank", data);
|
||||
};
|
||||
|
||||
// 获取当前用户的金币统计,可按门店查询。
|
||||
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]
|
||||
);
|
||||
};
|
||||
|
||||
+226
-737
File diff suppressed because it is too large
Load Diff
+25
-50
@@ -1,5 +1,3 @@
|
||||
import { formatTimestamp } from "@/util";
|
||||
|
||||
const loadImage = (src) =>
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
@@ -456,29 +454,23 @@ export const generateShareImage = async (canvasId, data) => {
|
||||
// 2D 即时绘制,无需 ctx.draw()
|
||||
} catch (e) {
|
||||
console.error("generateShareImage 绘制失败:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
// 顶部导入与工具方法
|
||||
async function getCanvas2DContext(canvasId, targetWidth, targetHeight) {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve) => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query
|
||||
.select(`#${canvasId}`)
|
||||
.fields({ node: true, size: true })
|
||||
.exec((res) => {
|
||||
const canvasInfo = res && res[0];
|
||||
const { node: canvas } = canvasInfo || {};
|
||||
if (!canvas || typeof canvas.getContext !== "function") {
|
||||
reject(new Error(`canvas ${canvasId} not found`));
|
||||
return;
|
||||
}
|
||||
const { node: canvas } = res[0] || {};
|
||||
const ctx = canvas.getContext("2d");
|
||||
const dpr = uni.getSystemInfoSync().pixelRatio || 1;
|
||||
|
||||
const w = targetWidth || canvasInfo.width;
|
||||
const h = targetHeight || canvasInfo.height;
|
||||
const w = targetWidth || res[0].width;
|
||||
const h = targetHeight || res[0].height;
|
||||
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
@@ -567,7 +559,6 @@ export const sharePointData = async (canvasId, data) => {
|
||||
// 2D 即时绘制,无需 ctx.draw()
|
||||
} catch (e) {
|
||||
console.error("generateShareImage 绘制失败:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -635,7 +626,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
|
||||
);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
"/static/score-bg.png",
|
||||
"../static/score-bg.png",
|
||||
16 + (i % 9) * 30,
|
||||
290 + Math.ceil((i + 1) / 9) * 30,
|
||||
27,
|
||||
@@ -644,7 +635,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
|
||||
}
|
||||
renderText(
|
||||
ctx,
|
||||
item.ringX ? "X" : item.ring,
|
||||
item.ring,
|
||||
18,
|
||||
"#fed847",
|
||||
29.5 + (i % 9) * 30,
|
||||
@@ -657,7 +648,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
|
||||
ctx.drawImage(bgImg, 24 + rowIndex * 42, i > 5 ? 362 : 320, 38, 38);
|
||||
} else {
|
||||
ctx.drawImage(
|
||||
"/static/score-bg.png",
|
||||
"../static/score-bg.png",
|
||||
24 + rowIndex * 42,
|
||||
i > 5 ? 362 : 320,
|
||||
38,
|
||||
@@ -666,7 +657,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
|
||||
}
|
||||
renderText(
|
||||
ctx,
|
||||
item.ringX ? "X" : item.ring,
|
||||
item.ring,
|
||||
23,
|
||||
"#fed847",
|
||||
43 + rowIndex * 42,
|
||||
@@ -706,39 +697,22 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
);
|
||||
const bgImg = await loadCanvasImage(canvas, bgImgSrc);
|
||||
|
||||
// 头像与段位框属于装饰图片,缺失时使用兜底或跳过,避免阻断分享。
|
||||
const loadProfileImage = async (src, fallbackSrc = "", label = "") => {
|
||||
const normalizedSrc =
|
||||
typeof src === "string" && src.startsWith("../static/")
|
||||
? 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;
|
||||
};
|
||||
|
||||
const avatarImgPromise = loadProfileImage(
|
||||
user?.avatar,
|
||||
"/static/user-icon.png",
|
||||
"avatar"
|
||||
const avatarImgPromise = loadImage(user.avatar).then((path) =>
|
||||
loadCanvasImage(canvas, path)
|
||||
);
|
||||
const lvlImgPromise = loadImage(user.lvlImage).then((path) =>
|
||||
loadCanvasImage(canvas, path)
|
||||
);
|
||||
const lvlImgPromise = loadProfileImage(user?.lvlImage, "", "level");
|
||||
|
||||
let titleImageSrc = "/static/first-try-title.png";
|
||||
let titleImageSrc = "../static/first-try-title.png";
|
||||
if (type == 2) {
|
||||
titleImageSrc = "/static/practise-one-title.png";
|
||||
titleImageSrc = "../static/practise-one-title.png";
|
||||
} else if (type == 3) {
|
||||
titleImageSrc = "/static/practise-two-title.png";
|
||||
titleImageSrc = "../static/practise-two-title.png";
|
||||
}
|
||||
const titleImgPromise = loadCanvasImage(canvas, titleImageSrc);
|
||||
const scoreBgImgPromise = loadCanvasImage(canvas, "/static/score-bg.png");
|
||||
const qrCodeImgPromise = loadCanvasImage(canvas, "/static/qr-code.png");
|
||||
const scoreBgImgPromise = loadCanvasImage(canvas, "../static/score-bg.png");
|
||||
const qrCodeImgPromise = loadCanvasImage(canvas, "../static/qr-code.png");
|
||||
|
||||
const [avatarImg, lvlImg, titleImg, scoreBgImg, qrCodeImg] =
|
||||
await Promise.all([
|
||||
@@ -755,15 +729,17 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
|
||||
ctx.drawImage(bgImg, 0, 0, width, height);
|
||||
|
||||
if (avatarImg) drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20);
|
||||
if (lvlImg) ctx.drawImage(lvlImg, 12, 15, 42, 42);
|
||||
drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20);
|
||||
ctx.drawImage(lvlImg, 12, 15, 42, 42);
|
||||
|
||||
renderText(ctx, user.nickName, 13, "#fff", 58, 34);
|
||||
renderRankTitle(ctx, user.lvlName);
|
||||
|
||||
let subTitle = "正式开启弓箭手之路";
|
||||
if (type > 1) {
|
||||
subTitle = `今日弓箭练习打卡 ${formatTimestamp(data.startTime)}`;
|
||||
subTitle = `今日弓箭练习打卡 ${data.createdAt
|
||||
.split(" ")[0]
|
||||
.replaceAll("-", ".")}`;
|
||||
}
|
||||
|
||||
ctx.drawImage(titleImg, (width - 160) / 2, 160, 160, 40);
|
||||
@@ -772,14 +748,14 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
renderText(ctx, subTitle, 18, "#fff", width / 2, 224, "center");
|
||||
|
||||
renderText(ctx, "共", 14, "#fff", 122, 300);
|
||||
const totalRing = data.details.reduce((last, next) => last + next.ring, 0);
|
||||
const totalRing = data.arrows.reduce((last, next) => last + next.ring, 0);
|
||||
renderText(ctx, totalRing, 14, "#fed847", 148, 300, "center");
|
||||
renderText(ctx, "环", 14, "#fff", 161, 300);
|
||||
|
||||
renderLine(ctx, 77);
|
||||
renderLine(ctx, 185);
|
||||
|
||||
renderScores(ctx, data.details, scoreBgImg);
|
||||
renderScores(ctx, data.arrows, scoreBgImg);
|
||||
|
||||
ctx.drawImage(qrCodeImg, width * 0.06, height * 0.87, 52, 52);
|
||||
renderText(ctx, "射灵平台", 12, "#fff", width * 0.26, height * 0.9);
|
||||
@@ -802,7 +778,6 @@ export async function sharePractiseData(canvasId, type, user, data) {
|
||||
// 2D 模式下无需 ctx.draw()
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,31 +18,31 @@ const props = defineProps({
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 0"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg.png"
|
||||
src="../static/app-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 1"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg2.png"
|
||||
src="../static/app-bg2.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 2"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg3.png"
|
||||
src="../static/app-bg3.png"
|
||||
:style="{ height: capsuleHeight + 50 + 'px' }"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 3"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg4.png"
|
||||
src="../static/app-bg4.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 4"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg5.png"
|
||||
src="../static/app-bg5.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
@@ -51,49 +51,6 @@ const props = defineProps({
|
||||
src="https://static.shelingxingqiu.com/attachment/2026-01-05/dfgf3b5kp459tfyn0f.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 6"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/rank/rank-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 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 === 12"
|
||||
src="../static/device-assets/my-device-unbound-background.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="bg-image"
|
||||
v-if="type === 10"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/vip/vip-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="bg-overlay" v-if="type === 0"></view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -110,7 +67,7 @@ const props = defineProps({
|
||||
|
||||
.bg-image {
|
||||
width: 100%;
|
||||
/* height: 100%; */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bg-overlay {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script setup>
|
||||
const tabs = [
|
||||
{ image: "../static/tab-vip.png" },
|
||||
{ image: "https://static.shelingxingqiu.com/shootmini/static/tab-point-book.png" },
|
||||
{ image: "https://static.shelingxingqiu.com/shootmini/static/tab-mall.png" },
|
||||
{ image: "../static/tab-point-book.png" },
|
||||
{ image: "../static/tab-mall.png" },
|
||||
];
|
||||
|
||||
function handleTabClick(index) {
|
||||
if (index === 0) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/member/be-vip",
|
||||
url: "/pages/be-vip",
|
||||
});
|
||||
}
|
||||
if (index === 1) {
|
||||
@@ -18,7 +18,7 @@ function handleTabClick(index) {
|
||||
}
|
||||
if (index === 2) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/device/device-intro",
|
||||
url: "/pages/device-intro",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,12 @@ function handleTabClick(index) {
|
||||
|
||||
<template>
|
||||
<view class="footer">
|
||||
<image class="footer-bg" src="https://static.shelingxingqiu.com/shootmini/static/tab-bg.png" mode="widthFix" />
|
||||
<image class="footer-bg" src="../static/tab-bg.png" mode="widthFix" />
|
||||
<view
|
||||
v-for="(tab, index) in tabs"
|
||||
:key="index"
|
||||
class="tab-item"
|
||||
@click="$clickSound(() => handleTabClick(index))"
|
||||
@click="handleTabClick(index)"
|
||||
:style="{
|
||||
width: index === 1 ? '36%' : '20%',
|
||||
}"
|
||||
@@ -43,13 +43,9 @@ function handleTabClick(index) {
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
/* height: 120px; */
|
||||
height: 190rpx;
|
||||
height: 120px;
|
||||
width: 100vw;
|
||||
/* position: relative; */
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
|
||||
+12
-25
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
@@ -26,33 +26,12 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 45,
|
||||
},
|
||||
sizeUnit: {
|
||||
type: String,
|
||||
default: "px",
|
||||
},
|
||||
imageMode: {
|
||||
type: String,
|
||||
default: "widthFix",
|
||||
},
|
||||
borderColor: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
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(
|
||||
() => [config.value, props.rankLvl],
|
||||
() => {
|
||||
@@ -72,7 +51,10 @@ watch(
|
||||
v-if="avatarFrame"
|
||||
:src="avatarFrame"
|
||||
mode="widthFix"
|
||||
:style="avatarFrameStyle"
|
||||
:style="{
|
||||
width: Number(size) + 10 + 'px',
|
||||
height: Number(size) + 10 + 'px',
|
||||
}"
|
||||
class="avatar-frame"
|
||||
/>
|
||||
<image
|
||||
@@ -96,8 +78,13 @@ watch(
|
||||
<view v-if="rank > 3" class="rank-view">{{ rank }}</view>
|
||||
<image
|
||||
:src="src || '../static/user-icon.png'"
|
||||
:mode="imageMode"
|
||||
:style="avatarImageStyle"
|
||||
mode="widthFix"
|
||||
:style="{
|
||||
width: size + 'px',
|
||||
height: size + 'px',
|
||||
minHeight: size + 'px',
|
||||
borderColor: borderColor || '#fff',
|
||||
}"
|
||||
class="avatar-image"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
|
||||
import { getBattleAPI, getUserGameState } from "@/apis";
|
||||
import { getCurrentGameAPI, getUserGameState } from "@/apis";
|
||||
import { debounce } from "@/util";
|
||||
import { returnToBattle } from "@/utils/matchReturn";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -19,15 +18,9 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
const loading = ref(false);
|
||||
const navigating = ref(false);
|
||||
|
||||
/** 统一获取当前环境 token,用于守卫:无有效 token 时不发起接口请求 */
|
||||
const getToken = () =>
|
||||
uni.getStorageSync(`${uni.getAccountInfoSync().miniProgram.envVersion}_token`);
|
||||
|
||||
onShow(async () => {
|
||||
navigating.value = false;
|
||||
if (user.value.id && getToken()) {
|
||||
if (user.value.id) {
|
||||
setTimeout(async () => {
|
||||
const state = await getUserGameState();
|
||||
updateGame(state.gaming, state.roomId);
|
||||
@@ -40,38 +33,24 @@ watch(
|
||||
async (value) => {
|
||||
if (!value.id) {
|
||||
updateGame(false, "");
|
||||
} else if (getToken()) {
|
||||
// 有有效 token 时才查询在局状态,避免 token 失效时反复发起无效请求
|
||||
} else {
|
||||
const state = await getUserGameState();
|
||||
updateGame(state.gaming, state.roomId);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const navigateOnce = (url) =>
|
||||
new Promise((resolve, reject) => {
|
||||
navigating.value = true;
|
||||
uni.navigateTo({
|
||||
url,
|
||||
success: resolve,
|
||||
fail: (error) => {
|
||||
navigating.value = false;
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const onClick = debounce(async () => {
|
||||
if (loading.value || navigating.value) return;
|
||||
if (loading.value) return;
|
||||
try {
|
||||
loading.value = true;
|
||||
const result = await getBattleAPI();
|
||||
if (result && result.matchId) {
|
||||
await returnToBattle(result, navigateOnce);
|
||||
return;
|
||||
}
|
||||
if (game.value.roomID) {
|
||||
await navigateOnce("/pages/battle-room?roomNumber=" + game.value.roomID);
|
||||
if (game.value.inBattle) {
|
||||
await uni.$checkAudio();
|
||||
const result = await getCurrentGameAPI();
|
||||
} else if (game.value.roomID) {
|
||||
uni.navigateTo({
|
||||
url: "/pages/battle-room?roomNumber=" + game.value.roomID,
|
||||
});
|
||||
} else {
|
||||
updateGame(false, "");
|
||||
}
|
||||
@@ -96,9 +75,9 @@ onBeforeUnmount(() => {
|
||||
class="back-to-game"
|
||||
@click="onClick"
|
||||
>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/back-to-game-bg.png" mode="widthFix" />
|
||||
<image src="../static/back-to-game-bg.png" mode="widthFix" />
|
||||
<block v-if="game.inBattle">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/pk-icon.png" mode="widthFix" />
|
||||
<image src="../static/pk-icon.png" mode="widthFix" />
|
||||
<text>返回进行中的对局</text>
|
||||
</block>
|
||||
<block v-else-if="game.roomID">
|
||||
|
||||
@@ -22,16 +22,15 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const normalRounds = computed(() => {
|
||||
const count = props.roundResults.findIndex((item) => !!item.ifGold);
|
||||
return count > 0 ? count : props.roundResults.length;
|
||||
});
|
||||
const normalRounds = computed(
|
||||
() => props.roundResults.length - props.goldenRound
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="guide-row">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/shooter.png" mode="widthFix" />
|
||||
<image src="../static/shooter.png" mode="widthFix" />
|
||||
<view
|
||||
:style="{
|
||||
marginBottom: '10px',
|
||||
@@ -42,7 +41,7 @@ const normalRounds = computed(() => {
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/battle-header-melee.png" mode="widthFix" />
|
||||
<image src="../static/battle-header-melee.png" mode="widthFix" />
|
||||
<text>蓝队({{ bluePoints }})</text>
|
||||
<text>红队({{ redPoints }})</text>
|
||||
</view>
|
||||
@@ -60,8 +59,8 @@ const normalRounds = computed(() => {
|
||||
</block>
|
||||
<view>
|
||||
<text>{{
|
||||
result.shoots[1] && result.shoots[1].length
|
||||
? result.shoots[1]
|
||||
result.blueArrows.length
|
||||
? result.blueArrows
|
||||
.map((item) => item.ring)
|
||||
.reduce((last, next) => last + next, 0)
|
||||
: ""
|
||||
@@ -95,8 +94,8 @@ const normalRounds = computed(() => {
|
||||
</block>
|
||||
<view>
|
||||
<text>{{
|
||||
result.shoots[2] && result.shoots[2].length
|
||||
? result.shoots[2]
|
||||
result.redArrows.length
|
||||
? result.redArrows
|
||||
.map((item) => item.ring)
|
||||
.reduce((last, next) => last + next, 0)
|
||||
: ""
|
||||
|
||||
@@ -27,27 +27,19 @@ defineProps({
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getMemberNicknameClass = (player = {}) => [
|
||||
"member-nickname",
|
||||
player.vip === true && player.sVip !== true ? "member-nickname--vip" : "",
|
||||
player.sVip === true ? "member-nickname--svip" : "",
|
||||
];
|
||||
|
||||
const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="container" :style="{ paddingTop: showHeader ? '5px' : '0' }">
|
||||
<image
|
||||
v-if="showHeader"
|
||||
:src="`https://static.shelingxingqiu.com/shootmini/static/battle-header${players.length ? '-melee' : ''}.png`"
|
||||
:src="`../static/battle-header${players.length ? '-melee' : ''}.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view
|
||||
v-if="!players.length && blueTeam.length && redTeam.length"
|
||||
class="players"
|
||||
:style="{ paddingTop: showHeader ? '48rpx' : '0' }"
|
||||
:style="{ paddingTop: showHeader ? '15px' : '0' }"
|
||||
>
|
||||
<view>
|
||||
<view
|
||||
@@ -59,16 +51,7 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
}"
|
||||
>
|
||||
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
||||
<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>
|
||||
<text class="player-name">{{ player.name }}</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="winner === 1"
|
||||
@@ -87,37 +70,21 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
}"
|
||||
>
|
||||
<Avatar :src="player.avatar" :rankLvl="player.rankLvl" :size="40" />
|
||||
<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>
|
||||
<text class="player-name">{{ player.name }}</text>
|
||||
</view>
|
||||
<image
|
||||
v-if="winner === 2"
|
||||
v-if="winner === 0"
|
||||
src="../static/winner-badge.png"
|
||||
mode="widthFix"
|
||||
class="right-winner-badge"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 大乱斗玩家列表:scroll-view 作为横向滚动容器 -->
|
||||
<!-- 小程序中 scroll-view 不支持直接 display:flex,需内部 wrapper view 承载 flex 布局 -->
|
||||
<!-- 仅当玩家 >5 人(内容溢出宽度)时才阻止冒泡,防止与外层 swiper 切换 tab 的手势冲突 -->
|
||||
<scroll-view
|
||||
<view
|
||||
v-if="players.length"
|
||||
class="players-melee"
|
||||
scroll-x
|
||||
:show-scrollbar="false"
|
||||
@touchmove="(e) => players.length > 5 && e.stopPropagation()"
|
||||
:style="{ paddingTop: showHeader ? '15px' : '0' }"
|
||||
>
|
||||
<view class="players-melee-inner">
|
||||
<view
|
||||
v-for="(player, index) in players"
|
||||
:key="index"
|
||||
@@ -132,19 +99,9 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
:size="40"
|
||||
:rank="showRank ? index + 1 : 0"
|
||||
/>
|
||||
<view
|
||||
v-if="isMember(player)"
|
||||
:class="['player-name', ...getMemberNicknameClass(player)]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ player.name }}</text>
|
||||
<text v-if="player.sVip === true" class="member-nickname__shine">
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="player-name">{{ player.name }}</text>
|
||||
<text class="player-name">{{ player.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -152,15 +109,13 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
.container {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 999;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.container > image:first-child {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
/* top: -5px; */
|
||||
top: -5px;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.players {
|
||||
display: flex;
|
||||
@@ -189,25 +144,24 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
justify-content: center;
|
||||
}
|
||||
.players-melee {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
/* 小程序 scroll-view 不支持直接 flex,通过内层 wrapper 承载横向排列 */
|
||||
.players-melee-inner {
|
||||
display: inline-flex;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
flex-wrap: nowrap;
|
||||
.players-melee::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
color: transparent;
|
||||
}
|
||||
.players-melee-inner > view {
|
||||
.players-melee > view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff9;
|
||||
font-size: 12px;
|
||||
/* padding-top: 7px; */
|
||||
padding-top: 7px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.player-name {
|
||||
@@ -218,13 +172,6 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
|
||||
text-overflow: ellipsis;
|
||||
text-align: center;
|
||||
}
|
||||
view.player-name {
|
||||
justify-content: center;
|
||||
}
|
||||
.player-name .member-nickname__text,
|
||||
.player-name .member-nickname__shine {
|
||||
font-size: 12px;
|
||||
}
|
||||
.left-winner-badge {
|
||||
position: absolute;
|
||||
width: 50px;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import BowTarget from "@/components/BowTarget.vue";
|
||||
@@ -9,9 +8,6 @@ import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && user.value.sVip !== true);
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
@@ -39,21 +35,7 @@ const props = defineProps({
|
||||
<view>
|
||||
<Avatar :src="user.avatar" :rankLvl="user.rankLvl" :size="45" />
|
||||
<view>
|
||||
<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.nickName }}</text>
|
||||
<text>{{ user.lvlName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -62,21 +44,21 @@ const props = defineProps({
|
||||
</view>
|
||||
</view>
|
||||
<view :style="{ width: '100%', marginBottom: '20px' }">
|
||||
<BowTarget :scores="arrows" :isSvip="isSVip" />
|
||||
<BowTarget :scores="arrows" />
|
||||
</view>
|
||||
<view class="desc">
|
||||
<text>{{ arrows.length }}</text>
|
||||
<text>支箭,共</text>
|
||||
<text>{{ arrows.reduce((a, b) => a + (b.ring || 0), 0) }}</text>
|
||||
<text>{{ arrows.reduce((a, b) => a + b.ring, 0) }}</text>
|
||||
<text>环</text>
|
||||
</view>
|
||||
<ScorePanel
|
||||
:completeEffect="false"
|
||||
:rowCount="total === 12 ? 6 : 9"
|
||||
:rowCount="arrows.length === 12 ? 6 : 9"
|
||||
:total="total"
|
||||
:arrows="arrows"
|
||||
:margin="total === 12 ? 4 : 1"
|
||||
:fontSize="total === 12 ? 25 : 22"
|
||||
:scores="arrows.map((a) => a.ring)"
|
||||
:margin="arrows.length === 12 ? 4 : 1"
|
||||
:fontSize="arrows.length === 12 ? 25 : 22"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
@@ -113,13 +95,6 @@ const props = defineProps({
|
||||
margin-left: 10px;
|
||||
color: #fff;
|
||||
}
|
||||
.bow-data-user-name {
|
||||
max-width: 300rpx;
|
||||
}
|
||||
.bow-data-user-name .member-nickname__text,
|
||||
.bow-data-user-name .member-nickname__shine {
|
||||
max-width: 300rpx;
|
||||
}
|
||||
.header > view:first-child > view:last-child > text:last-child {
|
||||
font-size: 10px;
|
||||
background-color: #5f51ff;
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
<script setup>
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { getDeviceBatteryAPI } from "@/apis";
|
||||
|
||||
const store = useStore();
|
||||
const { deviceBattery: power } = storeToRefs(store);
|
||||
const power = ref(0);
|
||||
const timer = ref(null);
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
power.value = data.battery;
|
||||
timer.value = setInterval(async () => {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
power.value = data.battery;
|
||||
}, 1000 * 10);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image src="../static/b-power.png" mode="widthFix" />
|
||||
<view>{{ power === null ? "电量--" : `电量${power}%` }}</view>
|
||||
<view>电量{{ power || 1 }}%</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,519 +0,0 @@
|
||||
<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>
|
||||
+90
-567
@@ -1,18 +1,9 @@
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
watch,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
computed,
|
||||
nextTick,
|
||||
getCurrentInstance,
|
||||
} from "vue";
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import PointSwitcher from "@/components/PointSwitcher.vue";
|
||||
import BowShotEffect from "@/components/BowShotEffect.vue";
|
||||
|
||||
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
|
||||
import { simulShootAPI, laserAimAPI, laserCloseAPI } from "@/apis";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
@@ -35,423 +26,73 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isSvip: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
default: "solo", // solo 单排,team 双排
|
||||
},
|
||||
missAsZero: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
stop: {
|
||||
type: Boolean,
|
||||
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 latestOne = ref(null);
|
||||
const bluelatestOne = ref(null);
|
||||
const prevScores = ref([]);
|
||||
const prevBlueScores = ref([]);
|
||||
const timer = ref(null);
|
||||
const dirTimer = ref(null);
|
||||
const angle = ref(null);
|
||||
const circleColor = ref("");
|
||||
const shotEffect = ref(null);
|
||||
const 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;
|
||||
|
||||
function getRoundText(shot) {
|
||||
if (shot?.ringX) return "X环";
|
||||
if (shot?.ring) return `${shot.ring}环`;
|
||||
return props.missAsZero ? "0环" : "未上靶";
|
||||
}
|
||||
|
||||
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(() => {
|
||||
latestOne.value = null;
|
||||
timer.value = null;
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
bluelatestOne.value = shot;
|
||||
timer.value = setTimeout(() => {
|
||||
bluelatestOne.value = null;
|
||||
timer.value = null;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function triggerShotEffect(team, shot, index, viewportMode = false) {
|
||||
const key = buildShotEffectKey(team, shot, index);
|
||||
|
||||
if (shotEffect.value?.team === "red") hiddenRedLatestKey.value = "";
|
||||
if (shotEffect.value?.team === "blue") hiddenBlueLatestKey.value = "";
|
||||
|
||||
if (team === "red") {
|
||||
latestOne.value = null;
|
||||
hiddenRedLatestKey.value = key;
|
||||
} else {
|
||||
bluelatestOne.value = null;
|
||||
hiddenBlueLatestKey.value = key;
|
||||
}
|
||||
|
||||
shotEffect.value = { key, team, shot, 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;
|
||||
() => props.scores,
|
||||
(newVal) => {
|
||||
if (newVal.length - prevScores.value.length === 1) {
|
||||
latestOne.value = newVal[newVal.length - 1];
|
||||
if (timer.value) clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
latestOne.value = null;
|
||||
hiddenRedLatestKey.value = "";
|
||||
if (shotEffect.value?.team === "red") shotEffect.value = null;
|
||||
}, 1000);
|
||||
}
|
||||
prevScores.value = [...newVal];
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.blueScores.length,
|
||||
(newLen, oldLen) => {
|
||||
if (newLen === oldLen + 1) {
|
||||
const latestShot = props.blueScores[newLen - 1];
|
||||
if (shouldPlayShotEffect(latestShot)) {
|
||||
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;
|
||||
() => props.blueScores,
|
||||
(newVal) => {
|
||||
if (newVal.length - prevBlueScores.value.length === 1) {
|
||||
bluelatestOne.value = newVal[newVal.length - 1];
|
||||
if (timer.value) clearTimeout(timer.value);
|
||||
timer.value = setTimeout(() => {
|
||||
bluelatestOne.value = null;
|
||||
hiddenBlueLatestKey.value = "";
|
||||
if (shotEffect.value?.team === "blue") shotEffect.value = null;
|
||||
}, 1000);
|
||||
}
|
||||
prevBlueScores.value = [...newVal];
|
||||
},
|
||||
{
|
||||
deep: true,
|
||||
}
|
||||
);
|
||||
|
||||
const safeTargetRadius = computed(() => {
|
||||
const radius = Number(props.targetRadius);
|
||||
return Number.isFinite(radius) && radius > 0 ? radius : 20;
|
||||
});
|
||||
|
||||
const currentHitRadiusPx = computed(() => {
|
||||
const radius = Number(
|
||||
pMode.value ? props.zoomHitRadiusPx : props.hitRadiusPx
|
||||
);
|
||||
return Number.isFinite(radius) && radius >= 0 ? radius : 0;
|
||||
});
|
||||
|
||||
function getShotPoint(shot, fallbackCenter = false) {
|
||||
const x = Number(shot?.x);
|
||||
const y = Number(shot?.y);
|
||||
if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
|
||||
return fallbackCenter ? { x: 0, y: 0 } : null;
|
||||
function calcRealX(num, offset = 3.4) {
|
||||
const len = 20.4 + num;
|
||||
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
||||
}
|
||||
|
||||
function getPointDirection(point) {
|
||||
if (!point) return null;
|
||||
const distance = Math.sqrt(point.x * point.x + point.y * point.y);
|
||||
if (distance === 0) return null;
|
||||
|
||||
return {
|
||||
x: point.x / distance,
|
||||
y: point.y / distance,
|
||||
};
|
||||
}
|
||||
|
||||
function formatPxOffset(value) {
|
||||
if (!value) return "";
|
||||
const operator = value > 0 ? "+" : "-";
|
||||
return ` ${operator} ${Math.abs(value)}px`;
|
||||
}
|
||||
|
||||
function formatTargetPosition(percent, offset) {
|
||||
const pxOffset = formatPxOffset(offset);
|
||||
return pxOffset ? `calc(${percent}%${pxOffset})` : `${percent}%`;
|
||||
}
|
||||
|
||||
function getTargetPositionStyle(point, offsetPx = 0, extraOffset = {}) {
|
||||
if (!point) return { display: "none" };
|
||||
|
||||
const radius = safeTargetRadius.value;
|
||||
const diameter = radius * 2;
|
||||
const direction = getPointDirection(point);
|
||||
const xOffset = (direction ? direction.x * offsetPx : 0) + (extraOffset.x || 0);
|
||||
const yOffset = (direction ? -direction.y * offsetPx : 0) + (extraOffset.y || 0);
|
||||
const leftPercent = ((point.x + radius) / diameter) * 100;
|
||||
const topPercent = ((radius - point.y) / diameter) * 100;
|
||||
|
||||
return {
|
||||
left: formatTargetPosition(leftPercent, xOffset),
|
||||
top: formatTargetPosition(topPercent, yOffset),
|
||||
transform: "translate(-50%, -50%)",
|
||||
};
|
||||
}
|
||||
|
||||
function getHitStyle(shot) {
|
||||
const radius = currentHitRadiusPx.value;
|
||||
const point = getShotPoint(shot);
|
||||
|
||||
return {
|
||||
...getTargetPositionStyle(point, radius),
|
||||
width: `${radius * 2}px`,
|
||||
height: `${radius * 2}px`,
|
||||
};
|
||||
}
|
||||
|
||||
function getSvipHitBgStyle(shot) {
|
||||
const radius = currentHitRadiusPx.value;
|
||||
const point = getShotPoint(shot);
|
||||
|
||||
return {
|
||||
...getTargetPositionStyle(point, radius),
|
||||
};
|
||||
}
|
||||
|
||||
function getRoundTipStyle(shot) {
|
||||
const point = getShotPoint(shot, true);
|
||||
return getTargetPositionStyle(
|
||||
point,
|
||||
shot?.ring ? currentHitRadiusPx.value : 0,
|
||||
{ y: ROUND_TIP_OFFSET_Y }
|
||||
);
|
||||
}
|
||||
|
||||
function getExperienceTipStyle(shot) {
|
||||
const point = getShotPoint(shot, true);
|
||||
return getTargetPositionStyle(
|
||||
point,
|
||||
shot?.ring ? currentHitRadiusPx.value : 0,
|
||||
{ y: EXPERIENCE_TIP_OFFSET_Y }
|
||||
);
|
||||
function calcRealY(num, offset = 3.4) {
|
||||
const len = num < 0 ? Math.abs(num) + 20.4 : 20.4 - num;
|
||||
return `calc(${(len / 40.8) * 100 - offset / 2}%)`;
|
||||
}
|
||||
const simulShoot = async () => {
|
||||
if (device.value.deviceId) {
|
||||
await simulShootAPI(
|
||||
device.value.deviceId,
|
||||
undefined,
|
||||
undefined,
|
||||
props.targetType
|
||||
);
|
||||
}
|
||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||
};
|
||||
const simulShoot2 = async () => {
|
||||
if (device.value.deviceId) {
|
||||
const r1 = Math.random() > 0.5 ? 0.01 : 0.02;
|
||||
await simulShootAPI(device.value.deviceId, r1, r1, props.targetType);
|
||||
}
|
||||
};
|
||||
|
||||
const openAim = async () => {
|
||||
await laserAimAPI();
|
||||
};
|
||||
|
||||
const closeAim = async () => {
|
||||
await laserCloseAPI();
|
||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId, 1, 1);
|
||||
};
|
||||
|
||||
const env = computed(() => {
|
||||
@@ -467,37 +108,35 @@ const arrowStyle = computed(() => {
|
||||
};
|
||||
});
|
||||
|
||||
async function onReceiveMessage(message) {
|
||||
if (Array.isArray(message)) return;
|
||||
if (message.type === MESSAGETYPESV2.ShootResult && message.shootData) {
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (
|
||||
message.shootData.playerId === user.value.id &&
|
||||
!message.shootData.ring &&
|
||||
message.shootData.angle >= 0
|
||||
msg.constructor === MESSAGETYPES.ShootSyncMeArrowID ||
|
||||
msg.constructor === MESSAGETYPES.ShootResult
|
||||
) {
|
||||
if (
|
||||
msg.userId === user.value.id &&
|
||||
!msg.target.ring &&
|
||||
msg.target.angle >= 0
|
||||
) {
|
||||
angle.value = null;
|
||||
setTimeout(() => {
|
||||
if (props.scores[0]) {
|
||||
circleColor.value =
|
||||
message.shootData.playerId === props.scores[0].playerId
|
||||
? "#ff4444"
|
||||
: "#1840FF";
|
||||
msg.userId === props.scores[0].playerId ? "#ff4444" : "#1840FF";
|
||||
}
|
||||
angle.value = message.shootData.angle;
|
||||
angle.value = msg.target.angle;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
void updateTargetRect();
|
||||
if (uni.onWindowResize) uni.onWindowResize(handleWindowResize);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
shotEffectRequestGeneration += 1;
|
||||
pendingShotEffect.value = null;
|
||||
if (timer.value) {
|
||||
clearTimeout(timer.value);
|
||||
timer.value = null;
|
||||
@@ -506,17 +145,12 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(dirTimer.value);
|
||||
dirTimer.value = null;
|
||||
}
|
||||
if (shakeTimer.value) {
|
||||
clearTimeout(shakeTimer.value);
|
||||
shakeTimer.value = null;
|
||||
}
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
if (uni.offWindowResize) uni.offWindowResize(handleWindowResize);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view :class="['container', { 'container--effecting': shotEffect }]">
|
||||
<view class="container">
|
||||
<view class="header" v-if="totalRound > 0">
|
||||
<text v-if="totalRound > 0" class="round-count">{{
|
||||
(currentRound > totalRound ? totalRound : currentRound) +
|
||||
@@ -524,7 +158,7 @@ onBeforeUnmount(() => {
|
||||
totalRound
|
||||
}}</text>
|
||||
</view>
|
||||
<view :class="['target', { 'target--shake': targetShaking }]">
|
||||
<view class="target">
|
||||
<view v-if="angle !== null" class="arrow-dir" :style="arrowStyle">
|
||||
<view :style="{ background: circleColor }">
|
||||
<image src="../static/dot-circle.png" mode="widthFix" />
|
||||
@@ -534,15 +168,21 @@ onBeforeUnmount(() => {
|
||||
<view
|
||||
v-if="latestOne && latestOne.ring && user.id === latestOne.playerId"
|
||||
class="e-value fade-in-out"
|
||||
:style="getExperienceTipStyle(latestOne)"
|
||||
:style="{
|
||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 20),
|
||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 40),
|
||||
}"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
<view
|
||||
v-if="latestOne"
|
||||
class="round-tip fade-in-out"
|
||||
:style="getRoundTipStyle(latestOne)"
|
||||
>{{ getRoundText(latestOne) }}
|
||||
:style="{
|
||||
left: calcRealX(latestOne.ring ? latestOne.x : 0, 28),
|
||||
top: calcRealY(latestOne.ring ? latestOne.y : 0, 28),
|
||||
}"
|
||||
>{{ latestOne.ring || "未上靶" }}<text v-if="latestOne.ring">环</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="
|
||||
@@ -551,86 +191,54 @@ onBeforeUnmount(() => {
|
||||
user.id === bluelatestOne.playerId
|
||||
"
|
||||
class="e-value fade-in-out"
|
||||
:style="getExperienceTipStyle(bluelatestOne)"
|
||||
:style="{
|
||||
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 20),
|
||||
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 40),
|
||||
}"
|
||||
>
|
||||
经验 +1
|
||||
</view>
|
||||
<view
|
||||
v-if="bluelatestOne"
|
||||
class="round-tip fade-in-out"
|
||||
:style="getRoundTipStyle(bluelatestOne)"
|
||||
>{{ getRoundText(bluelatestOne) }}</view
|
||||
:style="{
|
||||
left: calcRealX(bluelatestOne.ring ? bluelatestOne.x : 0, 28),
|
||||
top: calcRealY(bluelatestOne.ring ? bluelatestOne.y : 0, 28),
|
||||
}"
|
||||
>{{ bluelatestOne.ring || "未上靶"
|
||||
}}<text v-if="bluelatestOne.ring">环</text></view
|
||||
>
|
||||
<block v-for="(bow, index) in scores" :key="index">
|
||||
<image
|
||||
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideRedHit(index)"
|
||||
class="svip-hit-bg"
|
||||
src="../static/vip/svip-xuan.png"
|
||||
:style="getSvipHitBgStyle(bow)"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view
|
||||
v-if="bow.ring > 0 && !shouldHideRedHit(index)"
|
||||
v-if="bow.ring > 0"
|
||||
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||
index === scores.length - 1 && latestOne ? 'pump-in' : ''
|
||||
}`"
|
||||
:style="{
|
||||
...getHitStyle(bow),
|
||||
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
||||
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
||||
backgroundColor: mode === 'solo' ? '#00bf04' : '#FF0000',
|
||||
}"
|
||||
><text v-if="pMode">{{ index + 1 }}</text></view
|
||||
>
|
||||
</block>
|
||||
<block v-for="(bow, index) in blueScores" :key="index">
|
||||
<image
|
||||
v-if="pMode && isSvip && bow.ring > 0 && !shouldHideBlueHit(index)"
|
||||
class="svip-hit-bg"
|
||||
src="../static/vip/svip-xuan.png"
|
||||
:style="getSvipHitBgStyle(bow)"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view
|
||||
v-if="bow.ring > 0 && !shouldHideBlueHit(index)"
|
||||
v-if="bow.ring > 0"
|
||||
:class="`hit ${pMode ? 'b' : 's'}-point ${
|
||||
index === blueScores.length - 1 && bluelatestOne ? 'pump-in' : ''
|
||||
}`"
|
||||
:style="{
|
||||
...getHitStyle(bow),
|
||||
left: calcRealX(bow.x, pMode ? '3.4' : '2'),
|
||||
top: calcRealY(bow.y, pMode ? '3.4' : '2'),
|
||||
backgroundColor: '#1840FF',
|
||||
}"
|
||||
>
|
||||
<text v-if="pMode">{{ index + 1 }}</text>
|
||||
</view>
|
||||
</block>
|
||||
<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" />
|
||||
<image src="../static/bow-target.png" mode="widthFix" />
|
||||
</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">
|
||||
<PointSwitcher
|
||||
:onChange="(val) => (pMode = val)"
|
||||
@@ -640,8 +248,6 @@ onBeforeUnmount(() => {
|
||||
<view class="simul" v-if="env !== 'release'">
|
||||
<button @click="simulShoot">模拟</button>
|
||||
<button @click="simulShoot2">射箭</button>
|
||||
<button @click="openAim">开瞄</button>
|
||||
<button @click="closeAim">关瞄</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -652,22 +258,13 @@ onBeforeUnmount(() => {
|
||||
height: calc(100vw - 30px);
|
||||
padding: 0px 15px;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
.container--effecting {
|
||||
z-index: 10000;
|
||||
}
|
||||
.target {
|
||||
position: relative;
|
||||
margin: 10px;
|
||||
width: calc(100% - 20px);
|
||||
height: calc(100% - 20px);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.target--shake {
|
||||
animation: target-shake 0.26s ease-out;
|
||||
z-index: -1;
|
||||
}
|
||||
.e-value {
|
||||
position: absolute;
|
||||
@@ -693,55 +290,31 @@ onBeforeUnmount(() => {
|
||||
font-size: 24px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
@keyframes target-tip-fade-in-out {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.round-tip.fade-in-out,
|
||||
.e-value.fade-in-out {
|
||||
animation: target-tip-fade-in-out 1.2s ease forwards;
|
||||
}
|
||||
.target > image:last-child {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.svip-hit-bg {
|
||||
position: absolute;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
transform-origin: center center;
|
||||
animation: svip-hit-xuan 1.2s linear infinite;
|
||||
}
|
||||
.hit {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
z-index: 2;
|
||||
z-index: 1;
|
||||
color: #fff;
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.s-point {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
min-width: 4px;
|
||||
min-height: 4px;
|
||||
}
|
||||
.b-point {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
min-width: 10px;
|
||||
min-height: 10px;
|
||||
border: 1px solid #fff;
|
||||
z-index: 2;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -757,56 +330,6 @@ onBeforeUnmount(() => {
|
||||
transform: translate(-50%, -50%);*/
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
@keyframes svip-hit-xuan {
|
||||
0% {
|
||||
opacity: 0.9;
|
||||
transform: translate(-50%, -50%) rotate(0deg) scale(0.92);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) rotate(180deg) scale(1.08);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: translate(-50%, -50%) rotate(360deg) scale(0.92);
|
||||
}
|
||||
}
|
||||
@keyframes target-pump-in {
|
||||
from {
|
||||
transform: translate(-50%, -50%) scale(2);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes target-shake {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
14% {
|
||||
transform: translate(-20rpx, 8rpx);
|
||||
}
|
||||
28% {
|
||||
transform: translate(16rpx, -8rpx);
|
||||
}
|
||||
44% {
|
||||
transform: translate(-12rpx, 6rpx);
|
||||
}
|
||||
64% {
|
||||
transform: translate(8rpx, -4rpx);
|
||||
}
|
||||
82% {
|
||||
transform: translate(-4rpx, 2rpx);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
.hit.pump-in {
|
||||
animation: target-pump-in 0.3s ease-out forwards;
|
||||
transform-origin: center center;
|
||||
}
|
||||
.header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import AppBackground from "@/components/AppBackground.vue";
|
||||
import Header from "@/components/Header.vue";
|
||||
import ScreenHint from "@/components/ScreenHint.vue";
|
||||
import BackToGame from "@/components/BackToGame.vue";
|
||||
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||
import {laserAimAPI, getBattleAPI, matchGameAPI} from "@/apis";
|
||||
import { getCurrentGameAPI, laserAimAPI } from "@/apis";
|
||||
import { capsuleHeight, debounce } from "@/util";
|
||||
import { returnToBattle } from "@/utils/matchReturn";
|
||||
const emit = defineEmits(["scrolltolower"]);
|
||||
import AudioManager from "@/audioManager";
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
@@ -27,10 +25,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
usePageScroll: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isHome: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -47,31 +41,18 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
headerClass: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
titleStyle: {
|
||||
type: [String, Object, Array],
|
||||
default: () => ({}),
|
||||
},
|
||||
showBottom: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
const isIOS = uni.getDeviceInfo().osName === "ios";
|
||||
const showHint = ref(false);
|
||||
const hintType = ref(0);
|
||||
const isLoading = ref(false);
|
||||
const audioInitProgress = ref(1);
|
||||
const audioProgress = ref(0);
|
||||
const audioTimer = ref(null);
|
||||
|
||||
const showGlobalHint = (type) => {
|
||||
hintType.value = type;
|
||||
@@ -82,30 +63,63 @@ const hideGlobalHint = () => {
|
||||
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(() => {
|
||||
uni.$showHint = showGlobalHint;
|
||||
uni.$hideHint = hideGlobalHint;
|
||||
uni.$checkAudio = checkAudioProgress;
|
||||
showHint.value = false;
|
||||
});
|
||||
|
||||
const navigateTo = (url) =>
|
||||
new Promise((resolve, reject) => {
|
||||
uni.navigateTo({
|
||||
url,
|
||||
success: resolve,
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
|
||||
const backToGame = debounce(async () => {
|
||||
if (isLoading.value) return; // 防止重复点击
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const result = await getBattleAPI();
|
||||
if (result && result.matchId) {
|
||||
await returnToBattle(result, navigateTo);
|
||||
const game = await getCurrentGameAPI();
|
||||
if (!game || !game.gameId) {
|
||||
uni.showToast({
|
||||
title: "没有进行中的对局",
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
|
||||
showHint.value = false;
|
||||
} catch (error) {
|
||||
console.error("获取当前游戏失败:", error);
|
||||
} finally {
|
||||
@@ -117,14 +131,10 @@ const goBack = () => {
|
||||
uni.navigateBack();
|
||||
};
|
||||
|
||||
const cancelMatching = async () => {
|
||||
uni.$emit("cancelMatching");
|
||||
}
|
||||
|
||||
const goCalibration = async () => {
|
||||
await laserAimAPI();
|
||||
uni.navigateTo({
|
||||
url: "/pages/device/calibration",
|
||||
url: "/pages/calibration",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@@ -132,38 +142,20 @@ const goCalibration = async () => {
|
||||
<template>
|
||||
<view :style="{ paddingTop: capsuleHeight + 'px' }">
|
||||
<AppBackground :type="bgType" :bgColor="bgColor" />
|
||||
<slot v-if="$slots.header" name="header"></slot>
|
||||
<Header
|
||||
v-else-if="!isHome"
|
||||
:class="headerClass"
|
||||
v-if="!isHome"
|
||||
:title="title"
|
||||
:onBack="onBack"
|
||||
:whiteBackArrow="whiteBackArrow"
|
||||
:titleStyle="titleStyle"
|
||||
:style="
|
||||
usePageScroll
|
||||
? {
|
||||
position: 'sticky',
|
||||
top: capsuleHeight + 'px',
|
||||
zIndex: 10,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
/>
|
||||
<BackToGame v-if="showBackToGame" />
|
||||
<view v-if="usePageScroll">
|
||||
<slot></slot>
|
||||
</view>
|
||||
<scroll-view
|
||||
v-else
|
||||
:scroll-y="scroll"
|
||||
:enhanced="true"
|
||||
:bounces="false"
|
||||
:show-scrollbar="false"
|
||||
:lower-threshold="120"
|
||||
@scrolltolower="emit('scrolltolower')"
|
||||
:style="{
|
||||
height: `calc(100vh - ${capsuleHeight + (($slots.header || !isHome) ? 50 : 0)}px - ${
|
||||
height: `calc(100vh - ${capsuleHeight + (isHome ? 0 : 50)}px - ${
|
||||
$slots.bottom && showBottom ? (isIOS ? '75px' : '65px') : '0px'
|
||||
})`,
|
||||
}"
|
||||
@@ -206,7 +198,7 @@ const goCalibration = async () => {
|
||||
<button hover-class="none" @click="() => (showHint = false)">
|
||||
取消
|
||||
</button>
|
||||
<button hover-class="none" @click="$clickSound(cancelMatching)">确认</button>
|
||||
<button hover-class="none" @click="goBack">确认</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="hintType === 4" class="tip-content">
|
||||
@@ -219,19 +211,24 @@ const goCalibration = async () => {
|
||||
</view>
|
||||
</view>
|
||||
</ScreenHint>
|
||||
<view v-if="loading" class="audio-progress">
|
||||
<view v-if="audioInitProgress < 1" class="audio-progress">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-11-26/deihtj15xjwcz3c1tx.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view>
|
||||
<view :style="{ width: '100%' }"></view>
|
||||
<view :style="{ width: `${audioFinalProgress * 100}%` }">
|
||||
<!-- <image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-11-24/degu91a7si77sg9jqv.png"
|
||||
mode="widthFix"
|
||||
/> -->
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{ loadingText || "加载中..." }}</text>
|
||||
<text>若加载时间过长,请</text>
|
||||
<button hover-class="none" @click="restart">点击这里重启</button>
|
||||
</view>
|
||||
</view>
|
||||
<DeviceChargingDialog />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -273,7 +270,6 @@ const goCalibration = async () => {
|
||||
color: #666;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.audio-progress {
|
||||
z-index: 999;
|
||||
width: 100vw;
|
||||
@@ -287,13 +283,11 @@ const goCalibration = async () => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audio-progress > image:nth-child(1) {
|
||||
width: 140rpx;
|
||||
height: 150rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.audio-progress > view:nth-child(2) {
|
||||
width: 380rpx;
|
||||
height: 6rpx;
|
||||
@@ -304,24 +298,34 @@ const goCalibration = async () => {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.audio-progress > view:nth-child(2) > view {
|
||||
width: 100%;
|
||||
min-height: 6rpx;
|
||||
background: #ffe431;
|
||||
min-height: 6rpx;
|
||||
border-radius: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.audio-progress > view:nth-child(2) > view > image {
|
||||
width: 46rpx;
|
||||
height: 26rpx;
|
||||
}
|
||||
|
||||
.audio-progress > view:nth-child(3) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.audio-progress > view:nth-child(3) > text {
|
||||
font-size: 22rpx;
|
||||
color: #a2a2a2;
|
||||
text-align: center;
|
||||
line-height: 32rpx;
|
||||
}
|
||||
.audio-progress > view:nth-child(3) > button {
|
||||
font-size: 22rpx;
|
||||
color: #ffe431;
|
||||
line-height: 32rpx;
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,61 +17,38 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
/** 对战模式:0=未选 1=1v1 2=乱斗 3=2v2 4=3v3 */
|
||||
const battleMode = ref(0);
|
||||
/** 靶纸尺寸:0=未选 1=20cm 2=40cm */
|
||||
const targetMode = ref(0);
|
||||
const battleMode = ref(1);
|
||||
const loading = ref(false);
|
||||
const roomNumber = ref("");
|
||||
|
||||
const createRoom = debounce(async () => {
|
||||
// 校验必填项:对战模式与靶纸均必须选择
|
||||
if (!battleMode.value || !targetMode.value) {
|
||||
uni.showToast({ title: '请完善创建信息', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
if (game.value.inBattle) {
|
||||
uni.$showHint(1);
|
||||
return;
|
||||
}
|
||||
if (loading.value === true) return;
|
||||
loading.value = true;
|
||||
let keepLoading = false;
|
||||
let size = 2;
|
||||
if (battleMode.value === 2) size = 10;
|
||||
if (battleMode.value === 3) size = 4;
|
||||
if (battleMode.value === 4) size = 6;
|
||||
try {
|
||||
const result = await createRoomAPI(
|
||||
battleMode.value === 2 ? 2 : 1,
|
||||
battleMode.value === 2 ? 10 : size,
|
||||
targetMode.value*20,
|
||||
battleMode.value === 2 ? 10 : size
|
||||
);
|
||||
if (result.number) {
|
||||
props.onConfirm();
|
||||
await joinRoomAPI(result.number);
|
||||
keepLoading = true;
|
||||
uni.navigateTo({
|
||||
url: "/pages/battle-room?roomNumber=" + result.number + "&target=" + targetMode.value,
|
||||
fail: () => {
|
||||
loading.value = false;
|
||||
},
|
||||
url: "/pages/battle-room?roomNumber=" + result.number,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
if (!keepLoading) loading.value = false;
|
||||
}
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="target-options-header">
|
||||
<view class="target-options-header-line-left"></view>
|
||||
<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>
|
||||
<image src="../static/choose-battle-mode.png" mode="widthFix" />
|
||||
<view class="create-options">
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': battleMode === 1 }"
|
||||
@@ -98,26 +75,7 @@ const createRoom = debounce(async () => {
|
||||
<text>乱斗模式(3-10人)</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-options-header">
|
||||
<view class="target-options-header-line-left"></view>
|
||||
<view class="target-options-header-title">选择靶纸</view>
|
||||
<view class="target-options-header-line-right"></view>
|
||||
</view>
|
||||
<view class="target-options">
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': targetMode === 1 }"
|
||||
@click="() => (targetMode = 1)"
|
||||
>
|
||||
<text>20厘米全环靶</text>
|
||||
</view>
|
||||
<view
|
||||
:class="{ 'battle-btn': true, 'battle-choosen': targetMode === 2 }"
|
||||
@click="() => (targetMode = 2)"
|
||||
>
|
||||
<text>40厘米全环靶</text>
|
||||
</view>
|
||||
</view>
|
||||
<SButton :disabled="loading" :onClick="() => { $clickSound(); return createRoom(); }">创建房间</SButton>
|
||||
<SButton :onClick="createRoom">创建房间</SButton>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -128,7 +86,6 @@ const createRoom = debounce(async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 44rpx;
|
||||
}
|
||||
.container > image:first-child {
|
||||
width: 45%;
|
||||
@@ -143,50 +100,6 @@ const createRoom = debounce(async () => {
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.target-options-header{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.target-options-header-title-img{
|
||||
width: 196rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
.target-options-header-title{
|
||||
width: 112rpx;
|
||||
height: 40rpx;
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 400;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
color: #FFEFBA;
|
||||
margin: 0 18rpx;
|
||||
}
|
||||
.target-options-header-line-left{
|
||||
width: 214rpx;
|
||||
height: 0rpx;
|
||||
border-radius: 0rpx 0rpx 0rpx 0rpx;
|
||||
border: 1rpx solid;
|
||||
border-image: linear-gradient(90deg, rgba(133, 119, 96, 0), rgba(133, 119, 96, 1)) 1 1;
|
||||
}
|
||||
.target-options-header-line-right{
|
||||
width: 214rpx;
|
||||
height: 0rpx;
|
||||
border-radius: 0rpx 0rpx 0rpx 0rpx;
|
||||
border: 1rpx solid;
|
||||
border-image: linear-gradient(90deg, rgba(133, 119, 96, 1), rgba(133, 119, 96, 0)) 1 1;
|
||||
}
|
||||
.target-options {
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.battle-btn {
|
||||
width: 45%;
|
||||
height: 55px;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<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 = [
|
||||
"../static/long-bubble.png",
|
||||
"../static/long-bubble-middle.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/long-bubble-tall.png",
|
||||
"../static/long-bubble-tall.png",
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/shooter.png" mode="widthFix" />
|
||||
<image src="../static/shooter.png" mode="widthFix" />
|
||||
<view>
|
||||
<image
|
||||
v-if="!noBg"
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
noBg: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image class="shooter2" src="https://static.shelingxingqiu.com/shootmini/static/shooter2.png" mode="widthFix" />
|
||||
<view class="bg-box">
|
||||
<image
|
||||
class="bg"
|
||||
v-if="!noBg"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/long-bubble-border.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<slot />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 26rpx 0 28rpx;
|
||||
margin-bottom: 14rpx;
|
||||
width: clac(100% - 54rpx);
|
||||
}
|
||||
.container .shooter2 {
|
||||
display: block;
|
||||
width: 133rpx;
|
||||
height: 144rpx;
|
||||
}
|
||||
.container .bg-box {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 128rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
.container .bg-box .bg {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
+35
-105
@@ -6,7 +6,7 @@ import Avatar from "@/components/Avatar.vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, game } = storeToRefs(store);
|
||||
const { user } = storeToRefs(store);
|
||||
|
||||
const currentPage = computed(() => {
|
||||
const pages = getCurrentPages();
|
||||
@@ -26,10 +26,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
titleStyle: {
|
||||
type: [String, Object, Array],
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const onClick = () => {
|
||||
@@ -59,15 +55,11 @@ const signin = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && user.value.sVip !== true);
|
||||
|
||||
const loading = ref(false);
|
||||
const showLoader = ref(false);
|
||||
const pointBook = ref(null);
|
||||
const showProgress = ref(false);
|
||||
const heat = ref(0);
|
||||
/** 房间号按钮动态定位样式(position: fixed,根据胶囊真实位置计算,脱离 flex 流避免挤压标题) */
|
||||
const battleRoomBtnStyle = ref({});
|
||||
|
||||
const updateLoading = (value) => {
|
||||
loading.value = value;
|
||||
};
|
||||
@@ -88,26 +80,20 @@ onMounted(() => {
|
||||
pointBook.value = uni.getStorageSync("last-point-book");
|
||||
}
|
||||
}
|
||||
// 仅在对战房间页获取胶囊位置,按钮用 fixed 定位精确贴靠胶囊左侧(脱离 flex 流,不挤压标题)
|
||||
if (currentPage.route === "pages/battle-room") {
|
||||
try {
|
||||
const menuButtonRect = uni.getMenuButtonBoundingClientRect();
|
||||
const { windowWidth } = uni.getSystemInfoSync();
|
||||
battleRoomBtnStyle.value = {
|
||||
// 按钮右边缘距视口右侧 = 屏幕宽 - 胶囊左边缘 + 4px 安全间隙
|
||||
right: (windowWidth - menuButtonRect.left + 4) + "px",
|
||||
// 垂直位置与胶囊顶部对齐
|
||||
top: menuButtonRect.top + "px",
|
||||
// 高度与胶囊一致,视觉融合
|
||||
height: menuButtonRect.height + "px",
|
||||
};
|
||||
} catch (e) {
|
||||
// 获取失败时使用 CSS 兜底定位(28vw + 4px 作为 right,8px 作为 top)
|
||||
if (
|
||||
currentPage.route === "pages/team-battle" ||
|
||||
currentPage.route === "pages/melee-match"
|
||||
) {
|
||||
showLoader.value = true;
|
||||
}
|
||||
if (currentPage.route === "pages/team-battle") {
|
||||
showProgress.value = true;
|
||||
}
|
||||
uni.$on("update-header-loading", updateLoading);
|
||||
uni.$on("update-hot", updateHot);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("update-header-loading", updateLoading);
|
||||
uni.$off("update-hot", updateHot);
|
||||
});
|
||||
</script>
|
||||
@@ -122,11 +108,7 @@ onBeforeUnmount(() => {
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<view
|
||||
:style="[{ color: whiteBackArrow ? '#fff' : '#000' }, titleStyle]"
|
||||
>
|
||||
<slot v-if="$slots.title" name="title"></slot>
|
||||
<template v-else>
|
||||
<view :style="{ color: whiteBackArrow ? '#fff' : '#000' }">
|
||||
<view
|
||||
v-if="currentPage === 'pages/point-book'"
|
||||
class="user-header"
|
||||
@@ -139,21 +121,7 @@ onBeforeUnmount(() => {
|
||||
:size="40"
|
||||
borderColor="#333"
|
||||
/>
|
||||
<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>
|
||||
<text class="truncate">{{ user.nickName }}</text>
|
||||
<image
|
||||
v-if="heat"
|
||||
:src="`../static/hot${heat}.png`"
|
||||
@@ -167,8 +135,8 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
<block
|
||||
v-if="
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) === -1 ||
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) === 11
|
||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) === -1 ||
|
||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) === 10
|
||||
"
|
||||
>
|
||||
<text>{{ title }}</text>
|
||||
@@ -176,12 +144,12 @@ onBeforeUnmount(() => {
|
||||
<block
|
||||
v-if="
|
||||
title &&
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) !== -1 &&
|
||||
'-箭前准备-感知距离-小试牛刀'.indexOf(title) !== 11
|
||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== -1 &&
|
||||
'-凹造型-感知距离-小试牛刀'.indexOf(title) !== 10
|
||||
"
|
||||
>
|
||||
<view class="first-try-steps">
|
||||
<text :class="title === '-箭前准备' ? 'current-step' : ''">箭前准备</text>
|
||||
<text :class="title === '-凹造型' ? 'current-step' : ''">凹造型</text>
|
||||
<text>-</text>
|
||||
<text :class="title === '-感知距离' ? 'current-step' : ''"
|
||||
>感知距离</text
|
||||
@@ -192,8 +160,13 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
</view>
|
||||
</block>
|
||||
</template>
|
||||
</view>
|
||||
<image
|
||||
:style="{ opacity: showLoader && loading ? 0 : 0 }"
|
||||
src="../static/btn-loading.png"
|
||||
mode="widthFix"
|
||||
class="loading"
|
||||
/>
|
||||
<view v-if="pointBook" class="point-book-info">
|
||||
<text>{{ pointBook.bowType.name }}</text>
|
||||
<text>{{ pointBook.distance }} 米</text>
|
||||
@@ -211,26 +184,9 @@ onBeforeUnmount(() => {
|
||||
}}</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
v-if="
|
||||
currentPage === 'pages/team-battle' ||
|
||||
currentPage === 'pages/team-battle/index'
|
||||
"
|
||||
class="battle-progress"
|
||||
>
|
||||
<view v-if="showProgress" class="battle-progress">
|
||||
<HeaderProgress />
|
||||
</view>
|
||||
<!-- 对战房间:整个胶囊为分享按钮,房号从 Store 读取;fixed 定位紧靠系统胶囊左侧 -->
|
||||
<button
|
||||
v-if="currentPage === 'pages/battle-room' && game.roomNumber"
|
||||
open-type="share"
|
||||
hover-class="none"
|
||||
class="battle-room-number"
|
||||
:style="battleRoomBtnStyle"
|
||||
>
|
||||
<text class="battle-room-number__text">房号: {{ game.roomNumber }}</text>
|
||||
<image src="../static/share2.png" mode="widthFix" class="battle-room-number__icon" />
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -274,6 +230,14 @@ onBeforeUnmount(() => {
|
||||
font-size: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
.loading {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 10px;
|
||||
transition: all 0.3s ease;
|
||||
background-blend-mode: darken;
|
||||
animation: rotate 2s linear infinite;
|
||||
}
|
||||
.point-book-info {
|
||||
color: #333;
|
||||
position: fixed;
|
||||
@@ -311,45 +275,11 @@ onBeforeUnmount(() => {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
.user-header > text:nth-child(2),
|
||||
.user-header > .point-book-user-name {
|
||||
.user-header > text:nth-child(2) {
|
||||
font-weight: 500;
|
||||
font-size: 30rpx;
|
||||
color: #333333;
|
||||
margin: 0 20rpx;
|
||||
max-width: 300rpx;
|
||||
}
|
||||
/* 对战房间:整个胶囊作为分享按钮,fixed 定位脱离 flex 流,紧贴系统胶囊左侧 */
|
||||
.battle-room-number {
|
||||
position: fixed;
|
||||
/* 兜底定位(JS 获取胶囊位置失败时生效):约 28vw 对应胶囊区域左边缘 */
|
||||
right: calc(28vw + 4px);
|
||||
top: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 240rpx;
|
||||
height: 64rpx;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 96rpx;
|
||||
border: 1rpx solid #5b5758;
|
||||
padding: 0;
|
||||
}
|
||||
/* 重置 button 默认边框 */
|
||||
.battle-room-number::after {
|
||||
border: none;
|
||||
}
|
||||
.battle-room-number__text {
|
||||
width: 156rpx;
|
||||
height: 28rpx;
|
||||
font-weight: 400;
|
||||
font-size: 24rpx;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
line-height: 28rpx;
|
||||
}
|
||||
.battle-room-number__icon {
|
||||
width: 25rpx;
|
||||
height: 26rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import {
|
||||
getDirectionText,
|
||||
getInvalidShotAudioKey,
|
||||
getInvalidShotText,
|
||||
} from "@/util";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import { getDirectionText } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -23,20 +19,16 @@ const ended = ref(false);
|
||||
const halfTime = ref(false);
|
||||
const currentShot = ref(0);
|
||||
const totalShot = ref(0);
|
||||
/** 标记组件是否已完成挂载,防止 immediate watcher 在挂载前用旧 store 值触发意外播音 */
|
||||
const isMounted = ref(false);
|
||||
|
||||
watch(
|
||||
() => tips.value,
|
||||
(newVal) => {
|
||||
// 挂载完成前不播音(避免 immediate store watcher 用旧值触发多余播报)
|
||||
if (!isMounted.value) return;
|
||||
// 空字符串或含"重回"的 tips 均不播音
|
||||
if (!newVal || newVal.includes("重回")) return;
|
||||
let key = [];
|
||||
if (newVal.includes("重回")) return;
|
||||
if (currentRoundEnded.value) {
|
||||
currentRound.value += 1;
|
||||
// 播放当前轮次语音
|
||||
key.push(`第${["一", "二", "三", "四", "五"][currentRound.value]}轮`);
|
||||
key.push(`第${["一", "二", "三", "四", "五"][currentRound.value - 1]}轮`);
|
||||
}
|
||||
key.push(
|
||||
newVal.includes("你")
|
||||
@@ -55,48 +47,80 @@ const updateSound = () => {
|
||||
audioManager.setMuted(!sound.value);
|
||||
};
|
||||
|
||||
async function onReceiveMessage(message) {
|
||||
async function onReceiveMessage(messages = []) {
|
||||
if (ended.value) return;
|
||||
if (Array.isArray(message)) return;
|
||||
const { type, mode, current, shootData } = message;
|
||||
if (type === MESSAGETYPESV2.BattleStart) {
|
||||
melee.value = Boolean(mode > 3);
|
||||
// 优先使用后端返回的 shootNumber,降级则根据 mode 推算
|
||||
totalShot.value = message.shootNumber ?? (mode === 1 ? 3 : 2);
|
||||
currentRoundEnded.value = true;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play("比赛结束", false);
|
||||
} else if (type === MESSAGETYPESV2.ShootResult) {
|
||||
if (melee.value && current.playerId !== user.value.id) return;
|
||||
// 从 indexMap 按当前用户 id 取已射箭数,由后端维护准确值,不在前端自增。
|
||||
// 注意:后端在 ShootResult 中会将 playerId 重置为 0(无当前射手),
|
||||
// 因此不能依赖 playerId === user.id 判断,改为直接读取 indexMap[user.id]。
|
||||
// indexMap[user.id] 只在本人射箭后才增加,队友射箭时该值不变,逻辑等价且更准确。
|
||||
const myShot = current.indexMap?.[user.value.id];
|
||||
if (myShot !== undefined) currentShot.value = myShot;
|
||||
if (message.shootData) {
|
||||
let key = [];
|
||||
key.push(
|
||||
shootData.ring
|
||||
? `${shootData.ringX ? "X" : shootData.ring}环`
|
||||
: "未上靶"
|
||||
);
|
||||
if (shootData.angle !== null)
|
||||
key.push(`向${getDirectionText(shootData.angle)}调整`);
|
||||
audioManager.play(key, false);
|
||||
}
|
||||
} else if (type === MESSAGETYPESV2.NewRound) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.ShootResult) {
|
||||
if (melee.value && msg.userId !== user.value.id) return;
|
||||
if (msg.userId === user.value.id) currentShot.value++;
|
||||
if (msg.battleInfo && msg.userId === user.value.id) {
|
||||
const players = [
|
||||
...(msg.battleInfo.blueTeam || []),
|
||||
...(msg.battleInfo.redTeam || []),
|
||||
];
|
||||
const currentPlayer = players.find((p) => p.id === msg.userId);
|
||||
currentShot.value = 0;
|
||||
currentRound.value = current.round;
|
||||
currentRoundEnded.value = true;
|
||||
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
||||
try {
|
||||
if (
|
||||
currentPlayer &&
|
||||
currentPlayer.shotHistory &&
|
||||
currentPlayer.shotHistory[msg.battleInfo.currentRound]
|
||||
) {
|
||||
currentShot.value =
|
||||
currentPlayer.shotHistory[msg.battleInfo.currentRound].length;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!halfTime.value && msg.target) {
|
||||
let key = [];
|
||||
key.push(msg.target.ring ? `${msg.target.ring}环` : "未上靶");
|
||||
if (!msg.target.ring)
|
||||
key.push(`向${getDirectionText(msg.target.angle)}调整`);
|
||||
audioManager.play(key);
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.InvalidShot) {
|
||||
if (msg.userId === user.value.id) {
|
||||
uni.showToast({
|
||||
title: getInvalidShotText(shootData),
|
||||
title: "距离不足,无效",
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play(getInvalidShotAudioKey(shootData));
|
||||
audioManager.play("射击无效");
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.AllReady) {
|
||||
const { config } = msg.groupUserStatus;
|
||||
if (config && config.mode === 1) {
|
||||
totalShot.value = config.teamSize === 2 ? 3 : 2;
|
||||
}
|
||||
currentRoundEnded.value = true;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.constructor === MESSAGETYPES.MeleeAllReady) {
|
||||
melee.value = true;
|
||||
halfTime.value = false;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.constructor === MESSAGETYPES.CurrentRoundEnded) {
|
||||
currentShot.value = 0;
|
||||
if (msg.preRoundResult && msg.preRoundResult.currentRound) {
|
||||
currentRound.value = msg.preRoundResult.currentRound;
|
||||
currentRoundEnded.value = true;
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.HalfTimeOver) {
|
||||
halfTime.value = true;
|
||||
audioManager.play("中场休息");
|
||||
} else if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
audioManager.play("比赛结束");
|
||||
} else if (msg.constructor === MESSAGETYPES.FinalShoot) {
|
||||
totalShot.value = 0;
|
||||
audioManager.play("决金箭轮");
|
||||
tips.value = "即将开始...";
|
||||
currentRoundEnded.value = false;
|
||||
} else if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
ended.value = true;
|
||||
} else if (msg.constructor === MESSAGETYPES.BackToGame) {
|
||||
if (msg.battleInfo) {
|
||||
melee.value = msg.battleInfo.config.mode === 2;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const playSound = (key) => {
|
||||
@@ -107,36 +131,22 @@ const onUpdateTips = (newVal) => {
|
||||
tips.value = newVal;
|
||||
};
|
||||
|
||||
// 监听 Pinia store 中 totalShot 变化,用于比赛恢复时同步箭数(替代 uni.$emit 避免时序问题)
|
||||
// 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决重入时 totalShot 值不变 watch 不触发的问题)
|
||||
watch(() => store.game.totalShot, (newVal) => {
|
||||
if (newVal > 0) {
|
||||
totalShot.value = newVal;
|
||||
currentShot.value = store.game.currentShot;
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// 监听 Pinia store 中 tips 变化,用于比赛恢复时同步提示文案(替代 uni.$emit 避免时序问题)
|
||||
// 使用 immediate: true 确保组件创建时立即读取 store 当前值(解决 onShow 早于 onMounted 导致 uni.$emit 事件丢失的问题)
|
||||
// 注意:使用 != null 而非 if(newVal),确保空字符串 "" 也能触发清空(避免重新开赛时旧文案残留)
|
||||
watch(() => store.game.tips, (newVal) => {
|
||||
if (newVal != null) {
|
||||
tips.value = newVal;
|
||||
}
|
||||
}, { immediate: true });
|
||||
const onUpdateTotalShot = (newVal) => {
|
||||
currentShot.value = newVal.currentShot;
|
||||
totalShot.value = newVal.totalShot;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
uni.$on("update-shot", onUpdateTotalShot);
|
||||
uni.$on("update-tips", onUpdateTips);
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("play-sound", playSound);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("update-shot", onUpdateTotalShot);
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("play-sound", playSound);
|
||||
// 补充取消 update-tips 监听,防止页面重建时监听器叠加
|
||||
uni.$off("update-tips", onUpdateTips);
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
@@ -146,7 +156,10 @@ onBeforeUnmount(() => {
|
||||
<text>{{ (tips || "").replace(/你/g, "").replace(/重回/g, "") }}</text>
|
||||
<text v-if="totalShot > 0"> ({{ currentShot }}/{{ totalShot }}) </text>
|
||||
<button v-if="!!tips" hover-class="none" @click="updateSound">
|
||||
<image :src="`../static/sound${sound ? '' : '-off'}-yellow.png`" mode="widthFix" />
|
||||
<image
|
||||
:src="`../static/sound${sound ? '' : '-off'}-yellow.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</button>
|
||||
</view>
|
||||
</template>
|
||||
@@ -160,12 +173,10 @@ onBeforeUnmount(() => {
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.container > button:last-child {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.container > button:last-child > image {
|
||||
width: 36px;
|
||||
min-height: 36px;
|
||||
|
||||
@@ -105,7 +105,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<view class="matching">
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/matching-bg.png"
|
||||
src="../static/matching-bg.png"
|
||||
mode="widthFix"
|
||||
class="matching-bg"
|
||||
/>
|
||||
@@ -123,7 +123,7 @@ onBeforeUnmount(() => {
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<button hover-class="none" @click="$clickSound(stopMatch)">取消匹配</button>
|
||||
<button hover-class="none" @click="stopMatch">取消匹配</button>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
<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>
|
||||
@@ -1,416 +0,0 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const OTA_OFFLINE_TEXT = "请先开启智能弓";
|
||||
const store = useStore();
|
||||
const { deviceStatus } = storeToRefs(store);
|
||||
|
||||
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 = () => {
|
||||
if (deviceStatus.value?.online !== true) {
|
||||
uni.showToast({
|
||||
title: OTA_OFFLINE_TEXT,
|
||||
icon: "none",
|
||||
});
|
||||
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>
|
||||
@@ -3,7 +3,7 @@ import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const { user } = storeToRefs(useStore());
|
||||
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
player: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
@@ -15,19 +15,6 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -36,41 +23,29 @@ const getMemberNicknameClass = (player = {}) => [
|
||||
:style="{ borderColor: player.id === user.id ? '#FED847' : '#fff3' }"
|
||||
>
|
||||
<image
|
||||
:style="{
|
||||
opacity:
|
||||
(scores[0] || []).length + (scores[1] || []).length === 12 ? 1 : 0,
|
||||
}"
|
||||
:style="{ opacity: scores.length === 12 ? 1 : 0 }"
|
||||
src="../static/checked-green.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image :src="player.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||
<view
|
||||
v-if="isMember(player)"
|
||||
:class="['player-score-name', ...getMemberNicknameClass(player)]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ player.name }}</text>
|
||||
<text v-if="player.sVip === true" class="member-nickname__shine">
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else>{{ player.name }}</text>
|
||||
<text>{{ player.name }}</text>
|
||||
<view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{ getRingText(scores[0]?.[index]) }}</text>
|
||||
<text>{{ scores[index] ? `${scores[index].ring}环` : "-" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{ getRingText(scores[1]?.[index]) }}</text>
|
||||
<text>{{
|
||||
scores[index + 6] ? `${scores[index + 6].ring}环` : "-"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<text
|
||||
>{{
|
||||
scores
|
||||
.map((s) => (s || []).reduce((last, next) => last + next.ring, 0))
|
||||
.reduce((last, next) => last + next, 0)
|
||||
scores.map((s) => s.ring).reduce((last, next) => last + next, 0)
|
||||
}}环</text
|
||||
>
|
||||
</view>
|
||||
@@ -114,13 +89,6 @@ const getMemberNicknameClass = (player = {}) => [
|
||||
text-overflow: ellipsis;
|
||||
width: 20%;
|
||||
}
|
||||
.player-score-name {
|
||||
width: 20%;
|
||||
}
|
||||
.player-score-name .member-nickname__text,
|
||||
.player-score-name .member-nickname__shine {
|
||||
font-size: 14px;
|
||||
}
|
||||
.container > view:nth-child(4) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -9,7 +9,7 @@ defineProps({
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
arrows: {
|
||||
scores: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
@@ -21,6 +21,10 @@ defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
totalRing: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const rowCount = new Array(6).fill(0);
|
||||
</script>
|
||||
@@ -56,19 +60,19 @@ const rowCount = new Array(6).fill(0);
|
||||
<view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{ arrows[index] ? `${arrows[index].ring}环` : "-" }}</text>
|
||||
<text>{{ scores[index] ? `${scores[index].ring}环` : "-" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view v-for="(_, index) in rowCount" :key="index">
|
||||
<text>{{
|
||||
arrows[index + 6] ? `${arrows[index + 6].ring}环` : "-"
|
||||
scores[index + 6] ? `${scores[index + 6].ring}环` : "-"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<text>{{ arrows.reduce((last, next) => last + next.ring, 0) }}环</text>
|
||||
<text>{{ totalRing }}环</text>
|
||||
<text>积分{{ totalScore }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
+33
-114
@@ -1,6 +1,4 @@
|
||||
<script setup>
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
|
||||
const props = defineProps({
|
||||
total: {
|
||||
type: Number,
|
||||
@@ -10,131 +8,70 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
removePlayer: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
/** 当前用户是否为房主;仅房主可见踢人按钮 */
|
||||
isOwner: {
|
||||
type: Boolean,
|
||||
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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="players">
|
||||
<view v-for="(_, index) in seats" :key="index">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/player-bg.png" mode="widthFix" />
|
||||
<view v-if="players[index] && players[index].name" class="avatar">
|
||||
<Avatar
|
||||
<image src="../static/player-bg.png" mode="widthFix" />
|
||||
<image
|
||||
v-if="players[index] && players[index].name"
|
||||
:src="players[index].avatar || '../static/user-icon.png'"
|
||||
:size="40"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<text
|
||||
:style="{ opacity: players[index] && !!players[index].state ? 1 : 0 }"
|
||||
>已准备</text
|
||||
>
|
||||
</view>
|
||||
<view v-else class="player-unknow">
|
||||
<image src="../static/question-mark.png" mode="widthFix" />
|
||||
</view>
|
||||
<view
|
||||
v-if="players[index] && players[index].name && isMember(players[index])"
|
||||
:class="['player-seat-name', ...getMemberNicknameClass(players[index])]"
|
||||
>
|
||||
<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>
|
||||
<!-- <image
|
||||
<text v-if="players[index] && players[index].name">{{
|
||||
players[index].name
|
||||
}}</text>
|
||||
<text v-else :style="{ color: '#fff9' }">虚位以待</text>
|
||||
<view v-if="index === 0" class="founder">创建者</view>
|
||||
<image
|
||||
:src="`../static/player-${index + 1}.png`"
|
||||
mode="widthFix"
|
||||
class="player-bg"
|
||||
/> -->
|
||||
<!-- 仅房主(isOwner=true)且非空座位时展示踢人按钮 -->
|
||||
<button
|
||||
v-if="index > 0 && players[index] && isOwner"
|
||||
hover-class="none"
|
||||
class="remove-player"
|
||||
@click="() => removePlayer(players[index])"
|
||||
>
|
||||
<image src="../static/close-white.png" mode="widthFix" />
|
||||
</button>
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.players {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
row-gap: 20rpx;
|
||||
column-gap: 25rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
-moz-column-gap: 20px;
|
||||
column-gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
.players > view {
|
||||
width: calc(50% - 7px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
color: #fff;
|
||||
height: 176rpx;
|
||||
height: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.players > view > image:first-child {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
}
|
||||
.avatar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 24rpx;
|
||||
margin-top: 16rpx;
|
||||
.players > view > image:nth-child(2) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
margin: 0 10px;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.avatar > text {
|
||||
background-color: #2c261fb3;
|
||||
border: 1rpx solid #a3793f66;
|
||||
color: #fed847;
|
||||
font-size: 16rpx;
|
||||
border-radius: 20rpx;
|
||||
width: 70rpx;
|
||||
text-align: center;
|
||||
margin-top: -16rpx;
|
||||
position: relative;
|
||||
height: 28rpx;
|
||||
line-height: 28rpx;
|
||||
}
|
||||
.player-seat-name {
|
||||
.players > view > text:nth-child(3) {
|
||||
width: 20vw;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -143,48 +80,30 @@ const seats = new Array(props.total).fill(1);
|
||||
.founder {
|
||||
position: absolute;
|
||||
background-color: #fed847;
|
||||
top: 0;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
color: #000;
|
||||
font-size: 10px;
|
||||
padding: 2px 5px;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
/* .player-bg {
|
||||
.player-bg {
|
||||
position: absolute;
|
||||
width: 52px;
|
||||
right: 0;
|
||||
} */
|
||||
}
|
||||
.player-unknow {
|
||||
width: 84rpx;
|
||||
height: 84rpx;
|
||||
margin: 0 24rpx;
|
||||
border: 1rpx solid #fff3;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 10px;
|
||||
border: 1px solid #fff3;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #69686866;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.player-unknow > image {
|
||||
width: 40%;
|
||||
}
|
||||
.remove-player {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: absolute;
|
||||
top: 10rpx;
|
||||
right: 0;
|
||||
}
|
||||
.remove-player > image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
avatar: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
blueTeam: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
redTeam: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
currentShooterId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image v-if="avatar" class="avatar" :src="avatar" mode="widthFix" />
|
||||
<view
|
||||
v-if="blueTeam.length && redTeam.length"
|
||||
:style="{ height: 20 + blueTeam.length * 20 + 'px' }"
|
||||
>
|
||||
<view
|
||||
v-for="(player, index) in blueTeam"
|
||||
:key="index"
|
||||
:style="{
|
||||
top: index * 20 + 'px',
|
||||
zIndex: blueTeam.length - index,
|
||||
left: 0,
|
||||
}"
|
||||
>
|
||||
<image
|
||||
class="avatar"
|
||||
:src="player.avatar || '../static/user-icon.png'"
|
||||
mode="widthFix"
|
||||
:style="{
|
||||
borderColor: currentShooterId === player.id ? '#5fadff' : '#fff',
|
||||
}"
|
||||
/>
|
||||
<text
|
||||
:style="{
|
||||
color: currentShooterId === player.id ? '#5fadff' : '#fff',
|
||||
fontSize: currentShooterId === player.id ? 16 : 12 + 'px',
|
||||
}"
|
||||
>
|
||||
{{ player.name }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-if="!avatar"
|
||||
:style="{
|
||||
height: 20 + redTeam.length * 20 + 'px',
|
||||
}"
|
||||
>
|
||||
<view
|
||||
v-for="(player, index) in redTeam"
|
||||
:key="index"
|
||||
:style="{
|
||||
top: index * 20 + 'px',
|
||||
zIndex: redTeam.length - index,
|
||||
right: 0,
|
||||
}"
|
||||
>
|
||||
<text
|
||||
:style="{
|
||||
color: currentShooterId === player.id ? '#ff6060' : '#fff',
|
||||
fontSize: currentShooterId === player.id ? 16 : 12 + 'px',
|
||||
textAlign: 'right',
|
||||
}"
|
||||
>
|
||||
{{ player.name }}
|
||||
</text>
|
||||
<image
|
||||
class="avatar"
|
||||
:src="player.avatar || '../static/user-icon.png'"
|
||||
mode="widthFix"
|
||||
:style="{
|
||||
borderColor: currentShooterId === player.id ? '#ff6060' : '#fff',
|
||||
}"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: calc(100% - 30px);
|
||||
margin: 0 15px;
|
||||
margin-top: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.container > view {
|
||||
width: 50%;
|
||||
position: relative;
|
||||
}
|
||||
.container > view > view {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s linear;
|
||||
}
|
||||
.container > view > view > text {
|
||||
margin: 0 10px;
|
||||
overflow: hidden;
|
||||
width: 120px;
|
||||
transition: all 0.3s linear;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.red-avatar {
|
||||
border: 1px solid #ff6060;
|
||||
}
|
||||
.blue-avatar {
|
||||
border: 1px solid #5fadff;
|
||||
}
|
||||
</style>
|
||||
@@ -22,15 +22,6 @@ const props = defineProps({
|
||||
const like = ref(props.data.ifLike);
|
||||
const likeCount = ref(props.data.likeTotal || 0);
|
||||
|
||||
const isMember = (data = {}) => data.vip === true || data.sVip === true;
|
||||
|
||||
const getMemberNicknameClass = (data = {}) => [
|
||||
"point-rank-name",
|
||||
"member-nickname",
|
||||
data.vip === true && data.sVip !== true ? "member-nickname--vip" : "",
|
||||
data.sVip === true ? "member-nickname--svip" : "",
|
||||
];
|
||||
|
||||
watch(
|
||||
() => props.data,
|
||||
(newVal) => {
|
||||
@@ -62,13 +53,7 @@ const onClick = async () => {
|
||||
<view>
|
||||
<Avatar :src="data.avatar || '../static/user-icon.png'" :size="36" />
|
||||
<view>
|
||||
<view v-if="isMember(data)" :class="getMemberNicknameClass(data)">
|
||||
<text class="member-nickname__text">{{ data.name }}</text>
|
||||
<text v-if="data.sVip === true" class="member-nickname__shine">
|
||||
{{ data.name }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-else class="truncate">{{ data.name }}</text>
|
||||
<text class="truncate">{{ data.name }}</text>
|
||||
<view>
|
||||
<text>{{ data.totalDay }}天</text>
|
||||
<view />
|
||||
@@ -133,14 +118,6 @@ const onClick = async () => {
|
||||
color: #333333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
.rank-item > view:nth-child(2) > view:last-child > .point-rank-name {
|
||||
width: 200rpx;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
.point-rank-name .member-nickname__text,
|
||||
.point-rank-name .member-nickname__shine {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.rank-item > view:nth-child(2) > view:last-child > view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -65,7 +65,7 @@ onMounted(() => {
|
||||
</view>
|
||||
</view>
|
||||
<view class="right-part">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/bow-target.png" mode="widthFix" />
|
||||
<image src="../static/bow-target.png" mode="widthFix" />
|
||||
<view class="arrow-amount">
|
||||
<text>{{ data.actualTotalRing }}</text>
|
||||
<text>/</text>
|
||||
|
||||
@@ -93,7 +93,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/donate.png" mode="widthFix" />
|
||||
<image src="../static/donate.png" mode="widthFix" />
|
||||
<text>感谢您对我们公益项目的支持!</text>
|
||||
<view class="amounts">
|
||||
<button
|
||||
|
||||
@@ -52,14 +52,14 @@ onBeforeUnmount(() => {
|
||||
<view class="point-view1" v-if="bluePoint !== 0 || redPoint !== 0">
|
||||
<text>本轮蓝队</text>
|
||||
<text>{{
|
||||
(roundData.shoots[1] || []).reduce(
|
||||
(roundData.blueArrows || []).reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
)
|
||||
}}</text>
|
||||
<text>环,红队</text>
|
||||
<text>{{
|
||||
(roundData.shoots[2] || []).reduce(
|
||||
(roundData.redArrows || []).reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
)
|
||||
|
||||
@@ -88,7 +88,6 @@ watch(
|
||||
transform: translateY(100%);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
background-color: #372E1D;
|
||||
}
|
||||
.modal-content > image:first-child {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
const props = defineProps({
|
||||
rowCount: {
|
||||
type: Number,
|
||||
@@ -9,7 +9,7 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
arrows: {
|
||||
scores: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
@@ -26,27 +26,34 @@ const items = ref(new Array(props.total).fill(9));
|
||||
const width = ref(92);
|
||||
const itemWidth = ref(0);
|
||||
const bgImages = [
|
||||
"https://static.shelingxingqiu.com/shootmini/static/complete-light1.png",
|
||||
"https://static.shelingxingqiu.com/shootmini/static/complete-light2.png",
|
||||
"../static/complete-light1.png",
|
||||
"../static/complete-light2.png",
|
||||
];
|
||||
const bgIndex = ref(0);
|
||||
watch(
|
||||
() => props.total,
|
||||
(newValue) => {
|
||||
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>
|
||||
<template>
|
||||
<view class="container">
|
||||
<template v-if="total > 0 && arrows.length === total && completeEffect">
|
||||
<image
|
||||
v-for="(image, index) in bgImages"
|
||||
:key="image"
|
||||
:src="image"
|
||||
:class="[
|
||||
'complete-light',
|
||||
index === 0 ? 'complete-light--first' : 'complete-light--second',
|
||||
]"
|
||||
v-if="total > 0 && scores.length === total && completeEffect"
|
||||
:src="bgImages[bgIndex]"
|
||||
class="complete-light"
|
||||
:style="{
|
||||
width: `calc(${(100 / (rowCount + 2)) * rowCount}vw + ${
|
||||
(100 / (total * 2)) * (rowCount * 2 + (total === 12 ? 8 : 24))
|
||||
@@ -58,7 +65,6 @@ watch(
|
||||
top: `${total === 12 ? -2 : -3}vw`,
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
<view
|
||||
v-for="(_, index) in items"
|
||||
:key="index"
|
||||
@@ -73,10 +79,8 @@ watch(
|
||||
>
|
||||
<image src="../static/score-bg.png" mode="widthFix" />
|
||||
<text
|
||||
:style="{ fontWeight: arrows[index] !== undefined ? 'bold' : 'normal' }"
|
||||
>{{
|
||||
!arrows[index] ? "-" : arrows[index].ringX ? "X" : arrows[index].ring
|
||||
}}</text
|
||||
:style="{ fontWeight: scores[index] !== undefined ? 'bold' : 'normal' }"
|
||||
>{{ scores[index] !== undefined ? scores[index] : "-" }}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
@@ -115,30 +119,4 @@ watch(
|
||||
.complete-light {
|
||||
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>
|
||||
|
||||
@@ -1,49 +1,46 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
arrows: {
|
||||
scores: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
const getSum = (...arrows) => {
|
||||
const recordedArrows = arrows.filter(Boolean);
|
||||
if (!recordedArrows.length) return "-";
|
||||
const sum = recordedArrows.reduce(
|
||||
(total, arrow) => total + (Number(arrow.ring) || 0),
|
||||
0
|
||||
);
|
||||
return `${sum}环`;
|
||||
const getSum = (a, b, c) => {
|
||||
const sum = (Number(a) || 0) + (Number(b) || 0) + (Number(c) || 0);
|
||||
return sum > 0 ? sum + "环" : "-";
|
||||
};
|
||||
const roundsName = ["第一轮", "第二轮", "第三轮", "第四轮"];
|
||||
const getShowText = (arrow) => {
|
||||
if (!arrow) return "-";
|
||||
return arrow.ringX ? "X" : `${Number(arrow.ring) || 0}环`;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<view class="container">
|
||||
<view>
|
||||
<text :style="{ transform: 'translateX(-10%)' }">总成绩</text>
|
||||
<text>{{ arrows.reduce((last, next) => last + next.ring, 0) }}环</text>
|
||||
<text>{{ scores.reduce((last, next) => last + next, 0) }}环</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="(_, index) in new Array(
|
||||
Math.min(
|
||||
Math.ceil(arrows.length / 3) + (arrows.length % 3 === 0 ? 1 : 0),
|
||||
Math.ceil(scores.length / 3) + (scores.length % 3 === 0 ? 1 : 0),
|
||||
4
|
||||
)
|
||||
).fill(1)"
|
||||
:key="index"
|
||||
>
|
||||
<text>{{ roundsName[index] }}</text>
|
||||
<text>{{ getShowText(arrows[index * 3 + 0]) }}</text>
|
||||
<text>{{ getShowText(arrows[index * 3 + 1]) }}</text>
|
||||
<text>{{ getShowText(arrows[index * 3 + 2]) }}</text>
|
||||
<text>{{
|
||||
scores[index * 3 + 0] ? scores[index * 3 + 0] + "环" : "-"
|
||||
}}</text>
|
||||
<text>{{
|
||||
scores[index * 3 + 1] ? scores[index * 3 + 1] + "环" : "-"
|
||||
}}</text>
|
||||
<text>{{
|
||||
scores[index * 3 + 2] ? scores[index * 3 + 2] + "环" : "-"
|
||||
}}</text>
|
||||
<text :style="{ width: '40%', transform: 'translateX(20%)' }">{{
|
||||
getSum(
|
||||
arrows[index * 3 + 0],
|
||||
arrows[index * 3 + 1],
|
||||
arrows[index * 3 + 2]
|
||||
scores[index * 3 + 0],
|
||||
scores[index * 3 + 1],
|
||||
scores[index * 3 + 2]
|
||||
)
|
||||
}}</text>
|
||||
</view>
|
||||
|
||||
@@ -50,33 +50,21 @@ onMounted(() => {
|
||||
if (props.result.lvl > user.value.lvl) {
|
||||
showUpgrade.value = true;
|
||||
}
|
||||
totalRing.value = (props.result.details || []).reduce(
|
||||
totalRing.value = (props.result.arrows || []).reduce(
|
||||
(last, next) => last + next.ring,
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
const validArrows = computed(() => {
|
||||
return (props.result.arrows || []).filter(
|
||||
(arrow) => arrow.x !== -30 && arrow.y !== -30
|
||||
).length;
|
||||
});
|
||||
|
||||
const getRing = (arrow) => {
|
||||
if (!arrow) return "-";
|
||||
if (arrow.ringX) return "X";
|
||||
return Number(arrow.ring) || 0;
|
||||
};
|
||||
|
||||
const arrows = computed(() => {
|
||||
const data = new Array(props.total).fill(null);
|
||||
(props.result.details || []).forEach((arrow, index) => {
|
||||
data[index] = arrow;
|
||||
});
|
||||
return data;
|
||||
});
|
||||
|
||||
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;
|
||||
if (arrow && arrow.x !== -30 && arrow.y !== -30) return arrow.ring;
|
||||
return "-";
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -84,7 +72,7 @@ const openCoachComment = () => {
|
||||
<view class="container">
|
||||
<view :class="['container-header', showPanel ? 'scale-in' : 'scale-out']">
|
||||
<image :src="tipSrc" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/finish-frame.png" mode="widthFix" />
|
||||
<image src="../static/finish-frame.png" mode="widthFix" />
|
||||
<text
|
||||
>完成<text class="gold-text">{{ validArrows }}</text
|
||||
>箭,获得<text class="gold-text">{{ validArrows }}</text
|
||||
@@ -108,8 +96,8 @@ const openCoachComment = () => {
|
||||
</view>
|
||||
<view :style="{ gridTemplateColumns: `repeat(${rowCount}, 1fr)` }">
|
||||
<view v-for="(_, index) in new Array(total).fill(0)" :key="index">
|
||||
{{ getRing(arrows[index])
|
||||
}}<text v-if="getRing(arrows[index]) !== '-'">环</text>
|
||||
{{ getRing(result.arrows[index])
|
||||
}}<text v-if="getRing(result.arrows[index]) !== '-'">环</text>
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
@@ -120,10 +108,9 @@ const openCoachComment = () => {
|
||||
:onClick="onClickShare"
|
||||
/>
|
||||
<IconButton
|
||||
v-if="isMember"
|
||||
name="教练点评"
|
||||
src="../static/review.png"
|
||||
:onClick="openCoachComment"
|
||||
:onClick="() => (showComment = true)"
|
||||
/>
|
||||
</block>
|
||||
<SButton
|
||||
@@ -146,7 +133,7 @@ const openCoachComment = () => {
|
||||
}}</text
|
||||
>环的成绩,所有箭支上靶后的平均点间距为<text
|
||||
:style="{ color: '#fed847' }"
|
||||
>{{ Number((result?.interpretation?.spreadStability || 0).toFixed(2)) }}</text
|
||||
>{{ Number(result.average_distance.toFixed(2)) }}</text
|
||||
>,{{
|
||||
result.spreadEvaluation === "Dispersed"
|
||||
? "还需要持续改进哦~"
|
||||
@@ -173,8 +160,8 @@ const openCoachComment = () => {
|
||||
</view>
|
||||
</ScreenHint>
|
||||
<BowData
|
||||
:total="arrows.length"
|
||||
:arrows="result.details"
|
||||
:total="result.completed_arrows"
|
||||
:arrows="result.arrows"
|
||||
:show="showBowData"
|
||||
:onClose="() => (showBowData = false)"
|
||||
/>
|
||||
|
||||
@@ -27,22 +27,22 @@ const getContentHeight = () => {
|
||||
<view class="scale-in" :style="{ height: getContentHeight() }">
|
||||
<image
|
||||
v-if="mode === 'normal'"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/screen-hint-bg.png"
|
||||
src="../static/screen-hint-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'tall'"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/coach-comment.png"
|
||||
src="../static/coach-comment.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'square'"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/prompt-bg-square.png"
|
||||
src="../static/prompt-bg-square.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-if="mode === 'small'"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/finish-frame.png"
|
||||
src="../static/finish-frame.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<slot />
|
||||
|
||||
@@ -20,7 +20,7 @@ const props = defineProps({
|
||||
<template>
|
||||
<view class="container" :style="{ display: show ? 'flex' : 'none' }">
|
||||
<view class="scale-in">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/point-book-tip-bg.png" mode="widthFix" />
|
||||
<image src="../static/point-book-tip-bg.png" mode="widthFix" />
|
||||
<slot />
|
||||
</view>
|
||||
<IconButton
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import {
|
||||
getDirectionText,
|
||||
getInvalidShotAudioKey,
|
||||
getInvalidShotText,
|
||||
} from "@/util";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import { getDirectionText } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -42,18 +38,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
halfRest: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onStop: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
endAudioKey: {
|
||||
type: String,
|
||||
default: "比赛结束",
|
||||
},
|
||||
});
|
||||
|
||||
const barColor = ref("#fed847");
|
||||
@@ -65,7 +53,6 @@ const currentRoundEnded = ref(false);
|
||||
const ended = ref(false);
|
||||
const halfTime = ref(false);
|
||||
const wait = ref(0);
|
||||
const transitionStyle = ref("all 1s linear");
|
||||
|
||||
watch(
|
||||
() => props.tips,
|
||||
@@ -94,19 +81,7 @@ watch(
|
||||
|
||||
const resetTimer = (count) => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
const newVal = Math.round(count);
|
||||
|
||||
// 如果剩余时间增加(如重置),瞬间变化无动画
|
||||
if (newVal >= remain.value) {
|
||||
transitionStyle.value = "none";
|
||||
remain.value = newVal;
|
||||
setTimeout(() => {
|
||||
transitionStyle.value = "all 1s linear";
|
||||
}, 50);
|
||||
} else {
|
||||
remain.value = newVal;
|
||||
}
|
||||
|
||||
remain.value = Math.round(count);
|
||||
if (remain.value > 0) {
|
||||
timer.value = setInterval(() => {
|
||||
if (remain.value === 0) {
|
||||
@@ -121,12 +96,8 @@ const resetTimer = (count) => {
|
||||
watch(
|
||||
() => props.start,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
resetTimer(props.total);
|
||||
} else {
|
||||
remain.value = 0;
|
||||
clearInterval(timer.value);
|
||||
}
|
||||
if (newVal) resetTimer(props.total);
|
||||
else if (timer.value) clearInterval(timer.value);
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
@@ -145,41 +116,62 @@ const updateSound = () => {
|
||||
audioManager.setMuted(!sound.value);
|
||||
};
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||
const audioKey = props.melee && (halfTime.value || props.halfRest) ? "下半场开始" : "比赛开始";
|
||||
halfTime.value = false;
|
||||
audioManager.play(audioKey);
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play(props.endAudioKey, false);
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
let arrow = {};
|
||||
if (msg.details && Array.isArray(msg.details)) {
|
||||
arrow = msg.details[msg.details.length - 1];
|
||||
} else {
|
||||
if (!msg.shootData || String(msg.shootData.playerId) !== String(user.value.id)) return;
|
||||
if (msg.shootData) arrow = msg.shootData;
|
||||
}
|
||||
async function onReceiveMessage(messages = []) {
|
||||
if (ended.value) return;
|
||||
messages.forEach((msg) => {
|
||||
if (
|
||||
(props.battleId && msg.constructor === MESSAGETYPES.ShootResult) ||
|
||||
(!props.battleId && msg.constructor === MESSAGETYPES.ShootSyncMeArrowID)
|
||||
) {
|
||||
if (props.melee && msg.userId !== user.value.id) return;
|
||||
if (!halfTime.value && msg.target) {
|
||||
let key = [];
|
||||
key.push(arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶");
|
||||
if (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);
|
||||
} else if (msg.type === MESSAGETYPESV2.HalfRest) {
|
||||
halfTime.value = true;
|
||||
audioManager.play("中场休息");
|
||||
} else if (msg.type === MESSAGETYPESV2.InvalidShot) {
|
||||
key.push(msg.target.ring ? `${msg.target.ring}环` : "未上靶");
|
||||
if (!msg.target.ring)
|
||||
key.push(`向${getDirectionText(msg.target.angle)}调整`);
|
||||
audioManager.play(key);
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.InvalidShot) {
|
||||
if (msg.userId === user.value.id) {
|
||||
uni.showToast({
|
||||
title: getInvalidShotText(msg.shootData),
|
||||
title: "距离不足,无效",
|
||||
icon: "none",
|
||||
});
|
||||
audioManager.play(getInvalidShotAudioKey(msg.shootData));
|
||||
audioManager.play("射击无效");
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.AllReady) {
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.constructor === MESSAGETYPES.MeleeAllReady) {
|
||||
halfTime.value = false;
|
||||
audioManager.play("比赛开始");
|
||||
} else if (msg.constructor === MESSAGETYPES.CurrentRoundEnded) {
|
||||
currentRoundEnded.value = true;
|
||||
} else if (msg.constructor === MESSAGETYPES.HalfTimeOver) {
|
||||
if (props.battleId) {
|
||||
halfTime.value = true;
|
||||
audioManager.play("中场休息");
|
||||
return;
|
||||
}
|
||||
if (wait.value !== msg.wait) {
|
||||
setTimeout(() => {
|
||||
wait.value = msg.wait;
|
||||
if (msg.wait === 20) {
|
||||
halfTime.value = true;
|
||||
audioManager.play("中场休息", false);
|
||||
}
|
||||
if (msg.wait === 0) {
|
||||
halfTime.value = false;
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
} else if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
audioManager.play("比赛结束");
|
||||
} else if (msg.constructor === MESSAGETYPES.FinalShoot) {
|
||||
audioManager.play("决金箭轮");
|
||||
} else if (msg.constructor === MESSAGETYPES.MatchOver) {
|
||||
ended.value = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const playSound = (key) => {
|
||||
@@ -187,13 +179,13 @@ const playSound = (key) => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("update-remain", resetTimer);
|
||||
uni.$on("update-ramain", resetTimer);
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
uni.$on("play-sound", playSound);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("update-remain", resetTimer);
|
||||
uni.$off("update-ramain", resetTimer);
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("play-sound", playSound);
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
@@ -203,7 +195,7 @@ onBeforeUnmount(() => {
|
||||
<template>
|
||||
<view class="container" :style="{ display: show ? 'block' : 'none' }">
|
||||
<view>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/shooter.png" mode="widthFix" />
|
||||
<image src="../static/shooter.png" mode="widthFix" />
|
||||
<text>{{ tipContent }}</text>
|
||||
<button hover-class="none" @click="updateSound">
|
||||
<image
|
||||
@@ -218,7 +210,6 @@ onBeforeUnmount(() => {
|
||||
width: `${(remain / total) * 100}%`,
|
||||
backgroundColor: barColor,
|
||||
right: tips.includes('红队') ? 0 : 'unset',
|
||||
transition: transitionStyle,
|
||||
}"
|
||||
/>
|
||||
<text>剩余{{ remain }}秒</text>
|
||||
@@ -273,6 +264,7 @@ onBeforeUnmount(() => {
|
||||
height: 15px;
|
||||
border-radius: 15px;
|
||||
z-index: -1;
|
||||
transition: all 1s linear;
|
||||
}
|
||||
.container > view:last-child > text {
|
||||
font-size: 10px;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
|
||||
import { RoundGoldImages } from "@/constants";
|
||||
|
||||
const props = defineProps({
|
||||
tips: {
|
||||
type: String,
|
||||
@@ -20,95 +19,44 @@ const props = defineProps({
|
||||
const barColor = ref("");
|
||||
const remain = ref(15);
|
||||
const timer = ref(null);
|
||||
const loading = ref(false);
|
||||
const transitionStyle = ref("all 1s linear");
|
||||
const currentTeam = ref(null);
|
||||
|
||||
const updateRemain = (value) => {
|
||||
if (value.stop) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
return
|
||||
}
|
||||
// zeroThenReset:ToSomeoneShoot 到达时,若进度条仍在倒计时则先瞬间清零(约 150ms 停留)再显示下一玩家满值
|
||||
// 若进度条已到 0(loading 状态),直接切换满值
|
||||
if (value.zeroThenReset) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
const wasNonZero = remain.value > 0;
|
||||
// 更新下一玩家颜色和方向(在清零和满值时均生效)
|
||||
currentTeam.value = value.team;
|
||||
if (value.team === 'red') barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
|
||||
if (value.team === 'blue') barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
|
||||
transitionStyle.value = "none";
|
||||
if (wasNonZero) {
|
||||
// 瞬间清零,停留约 150ms 后切换为满值
|
||||
remain.value = 0;
|
||||
loading.value = true;
|
||||
setTimeout(() => {
|
||||
remain.value = value.value;
|
||||
loading.value = false;
|
||||
setTimeout(() => { transitionStyle.value = "all 1s linear"; }, 50);
|
||||
}, 150);
|
||||
} else {
|
||||
// 已在底部,直接切换满值
|
||||
remain.value = value.value;
|
||||
loading.value = false;
|
||||
setTimeout(() => { transitionStyle.value = "all 1s linear"; }, 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
loading.value = false;
|
||||
currentTeam.value = value.team
|
||||
if (value.team === 'red')
|
||||
barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
|
||||
if (value.team === 'blue')
|
||||
barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
|
||||
if (value.reset) {
|
||||
// 重置前先清除旧计时器,防止超时未射箭时旧 interval 残留,导致进度条震荡
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
// 重置时瞬间跳满格,禁用 CSS 过渡避免从旧值「涨到满」的动画
|
||||
transitionStyle.value = "none";
|
||||
remain.value = value.value;
|
||||
setTimeout(() => {
|
||||
transitionStyle.value = "all 1s linear";
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
const newVal = Math.round(value.value);
|
||||
// 如果剩余时间增加(如轮次切换重置),瞬间变化无动画;否则保持动画
|
||||
if (newVal >= remain.value) {
|
||||
transitionStyle.value = "none";
|
||||
remain.value = newVal;
|
||||
setTimeout(() => {
|
||||
transitionStyle.value = "all 1s linear";
|
||||
}, 50);
|
||||
} else {
|
||||
remain.value = newVal;
|
||||
}
|
||||
|
||||
// 启动前先清除旧计时器,防止多次 {stop:false} 事件叠加多个 interval
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
timer.value = setInterval(() => {
|
||||
loading.value = remain.value === 0;
|
||||
if (remain.value > 0) remain.value--;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.tips,
|
||||
(newVal) => {
|
||||
|
||||
if (newVal.includes("红队"))
|
||||
barColor.value = "linear-gradient( 180deg, #FFA0A0 0%, #FF6060 100%)";
|
||||
if (newVal.includes("蓝队"))
|
||||
barColor.value = "linear-gradient( 180deg, #9AB3FF 0%, #4288FF 100%)";
|
||||
if (newVal.includes("重回")) return;
|
||||
if (newVal.includes("红队") || newVal.includes("蓝队")) {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
remain.value = props.total;
|
||||
timer.value = setInterval(() => {
|
||||
if (remain.value > 0) remain.value--;
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
const updateRemain = (value) => {
|
||||
if (Math.ceil(value) === remain.value || Math.floor(value) === remain.value)
|
||||
return;
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
remain.value = Math.round(value);
|
||||
timer.value = setInterval(() => {
|
||||
if (remain.value > 0) remain.value--;
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
uni.$on("update-remain", updateRemain);
|
||||
uni.$on("update-ramain", updateRemain);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("update-remain", updateRemain);
|
||||
uni.$off("update-ramain", updateRemain);
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
});
|
||||
</script>
|
||||
@@ -118,19 +66,17 @@ onBeforeUnmount(() => {
|
||||
<image :src="RoundGoldImages[props.currentRound]" mode="widthFix" />
|
||||
<view
|
||||
:style="{
|
||||
justifyContent: currentTeam==='red' ? 'flex-end' : 'flex-start',
|
||||
justifyContent: tips.includes('红队') ? 'flex-end' : 'flex-start',
|
||||
}"
|
||||
>
|
||||
<view
|
||||
:style="{
|
||||
width: `${(remain / total) * 100}%`,
|
||||
background: barColor,
|
||||
right: currentTeam==='red' ? 0 : 'unset',
|
||||
transition: transitionStyle,
|
||||
right: tips.includes('红队') ? 0 : 'unset',
|
||||
}"
|
||||
/>
|
||||
<text v-if="!loading">剩余{{ remain }}秒</text>
|
||||
<text v-else>···</text>
|
||||
<text>剩余{{ remain }}秒</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -142,13 +88,11 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container > image {
|
||||
width: 380rpx;
|
||||
height: 80rpx;
|
||||
transform: translateY(18rpx);
|
||||
}
|
||||
|
||||
.container > view:last-child {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
@@ -160,12 +104,11 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.container > view:last-child > view {
|
||||
height: 24rpx;
|
||||
border-radius: 15px;
|
||||
transition: all 1s linear;
|
||||
}
|
||||
|
||||
.container > view:last-child > text {
|
||||
font-size: 18rpx;
|
||||
color: #fff;
|
||||
|
||||
+22
-48
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
@@ -11,12 +11,12 @@ import {
|
||||
loginAPI,
|
||||
getHomeData,
|
||||
getPhoneNumberAPI,
|
||||
getPhoneNumberAPIv2,
|
||||
getDeviceBatteryAPI,
|
||||
} from "@/apis";
|
||||
|
||||
import useStore from "@/store";
|
||||
const store = useStore();
|
||||
const { updateUser, updateDevice, clearDevice } = store;
|
||||
const { updateUser, updateDevice, updateOnline } = store;
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -31,10 +31,6 @@ const props = defineProps({
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
onSuccess: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
const agree = ref(false);
|
||||
const phone = ref("");
|
||||
@@ -47,12 +43,12 @@ const handleAgree = () => {
|
||||
|
||||
async function getphonenumber(e) {
|
||||
if (e.detail.code) {
|
||||
// const wxResult = await wxLogin();
|
||||
const result = await getPhoneNumberAPIv2({
|
||||
// ...e.detail,
|
||||
code: e.detail.code,
|
||||
const wxResult = await wxLogin();
|
||||
const result = await getPhoneNumberAPI({
|
||||
...e.detail,
|
||||
code: wxResult.code,
|
||||
});
|
||||
if (result.purePhoneNumber) phone.value = result.purePhoneNumber;
|
||||
if (result.phone) phone.value = result.phone;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,21 +60,6 @@ function onNicknameChange(e) {
|
||||
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 () => {
|
||||
if (loading.value) return;
|
||||
if (!phone.value) {
|
||||
@@ -105,35 +86,29 @@ const handleLogin = async () => {
|
||||
icon: "none",
|
||||
});
|
||||
}
|
||||
await doLogin();
|
||||
};
|
||||
|
||||
async function doLogin() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const wxResult = await wxLogin();
|
||||
const fileManager = uni.getFileSystemManager();
|
||||
const avatarBase64 = fileManager.readFileSync(avatarUrl.value, "base64");
|
||||
const base64Url = `data:image/png;base64,${avatarBase64}`;
|
||||
await loginAPI(phone.value, nickName.value, base64Url, wxResult.code);
|
||||
const result = await loginAPI(
|
||||
phone.value,
|
||||
nickName.value,
|
||||
base64Url,
|
||||
wxResult.code
|
||||
);
|
||||
const data = await getHomeData();
|
||||
if (data.user) updateUser(data.user);
|
||||
const devices = await getMyDevicesAPI();
|
||||
if (devices.bindings && devices.bindings.length) {
|
||||
updateDevice(
|
||||
devices.bindings[0].deviceId,
|
||||
devices.bindings[0].deviceName
|
||||
);
|
||||
} else {
|
||||
clearDevice();
|
||||
updateDevice(devices.bindings[0].deviceId, devices.bindings[0].deviceName);
|
||||
try {
|
||||
const data = await getDeviceBatteryAPI();
|
||||
updateOnline(data.online);
|
||||
} catch (error) {}
|
||||
}
|
||||
props.onClose();
|
||||
await props.onSuccess();
|
||||
} catch (error) {
|
||||
console.log("login error", error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
props.onClose();
|
||||
};
|
||||
|
||||
const openServiceLink = () => {
|
||||
@@ -157,7 +132,7 @@ const openPrivacyLink = () => {
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
resetForm();
|
||||
loading.value = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -203,11 +178,10 @@ onShow(() => {
|
||||
<text :style="{ color: noBg ? '#666' : '#fff' }">昵称:</text>
|
||||
<input
|
||||
type="nickname"
|
||||
:value="nickName"
|
||||
placeholder="请输入昵称"
|
||||
:placeholder-style="`color: ${noBg ? '#666' : '#fff9'} `"
|
||||
@input="onNicknameChange"
|
||||
@change="onNicknameChange"
|
||||
@blur="onNicknameBlur"
|
||||
:style="{ color: noBg ? '#333' : '#fff' }"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
interval: {
|
||||
@@ -14,24 +14,13 @@ const props = defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
current: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
onChange: {
|
||||
type: Function,
|
||||
default: (index) => {},
|
||||
},
|
||||
});
|
||||
|
||||
const currentIndex = ref(props.current);
|
||||
|
||||
watch(
|
||||
() => props.current,
|
||||
(index) => {
|
||||
currentIndex.value = index;
|
||||
}
|
||||
);
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const handleChange = (e) => {
|
||||
currentIndex.value = e.detail.current;
|
||||
@@ -86,7 +75,7 @@ const handleChange = (e) => {
|
||||
|
||||
.dots {
|
||||
position: absolute;
|
||||
bottom: 2%;
|
||||
bottom: 15%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
@@ -101,6 +90,6 @@ const handleChange = (e) => {
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
background-color: #fed847;
|
||||
background-color: #000;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,652 +0,0 @@
|
||||
<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,214 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
clickSound: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onClose: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
onConfirm: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const selectedTarget = ref(2);
|
||||
const showContainer = ref(false);
|
||||
const showContent = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
showContainer.value = true;
|
||||
setTimeout(() => {
|
||||
showContent.value = true;
|
||||
}, 100);
|
||||
} else {
|
||||
showContent.value = false;
|
||||
setTimeout(() => {
|
||||
showContainer.value = false;
|
||||
}, 100);
|
||||
}
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const playClickSound = () => {
|
||||
if (props.clickSound) {
|
||||
audioManager.play("点击按钮");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectTarget = (target) => {
|
||||
playClickSound();
|
||||
selectedTarget.value = target;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
playClickSound();
|
||||
props.onConfirm(selectedTarget.value);
|
||||
props.onClose();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
class="container"
|
||||
v-if="showContainer"
|
||||
:class="{ 'container-show': showContent }"
|
||||
@click="onClose"
|
||||
>
|
||||
<view
|
||||
class="modal-content"
|
||||
:class="{ 'modal-show': showContent }"
|
||||
@click.stop=""
|
||||
>
|
||||
<view class="header">
|
||||
<view class="header-title">
|
||||
<view class="header-title-line-left"></view>
|
||||
<text>选择靶型</text>
|
||||
<view class="header-title-line-right"></view>
|
||||
</view>
|
||||
<view class="close-btn" @click="onClose">
|
||||
<image src="../static/close-yellow.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="target-options">
|
||||
<view
|
||||
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 1 }"
|
||||
@click="handleSelectTarget(1)"
|
||||
>
|
||||
<text>20厘米全环靶</text>
|
||||
</view>
|
||||
<view style="width: 30rpx"></view>
|
||||
<view
|
||||
:class="{ 'target-btn': true, 'target-choosen': selectedTarget === 2 }"
|
||||
@click="handleSelectTarget(2)"
|
||||
>
|
||||
<text>40厘米全环靶</text>
|
||||
</view>
|
||||
</view>
|
||||
<SButton width="694rpx" :onClick="handleConfirm">确定</SButton>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: #00000099;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 999;
|
||||
}
|
||||
.container-show {
|
||||
opacity: 1;
|
||||
}
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
transform: translateY(100%);
|
||||
transition: all 0.3s ease;
|
||||
background: url("https://static.shelingxingqiu.com/attachment/2025-12-04/dep11770wzxg6o2alo.png")
|
||||
no-repeat center top;
|
||||
background-size: 100% auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 68rpx;
|
||||
padding-top: 44rpx;
|
||||
}
|
||||
.modal-show {
|
||||
transform: translateY(0%);
|
||||
}
|
||||
.header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin-bottom: 44rpx;
|
||||
}
|
||||
.header-title{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.header-title text{
|
||||
width: 196rpx;
|
||||
height: 40rpx;
|
||||
font-family: PingFang SC, PingFang SC;
|
||||
font-weight: 400;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
text-transform: none;
|
||||
color: #FFEFBA;
|
||||
}
|
||||
.header-title-line-left{
|
||||
width: 214rpx;
|
||||
height: 0rpx;
|
||||
border-radius: 0rpx 0rpx 0rpx 0rpx;
|
||||
border: 1rpx solid;
|
||||
border-image: linear-gradient(90deg, rgba(133, 119, 96, 1), rgba(133, 119, 96, 0)) 1 1;
|
||||
}
|
||||
.header-title-line-right{
|
||||
width: 214rpx;
|
||||
height: 0rpx;
|
||||
border-radius: 0rpx 0rpx 0rpx 0rpx;
|
||||
border: 1rpx solid;
|
||||
border-image: linear-gradient(90deg, rgba(133, 119, 96, 1), rgba(133, 119, 96, 0)) 1 1;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -10px;
|
||||
}
|
||||
.close-btn > image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.target-options {
|
||||
width: 750rpx;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: 38rpx;
|
||||
}
|
||||
.target-btn {
|
||||
width: 332rpx;
|
||||
height: 92rpx;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
border: 2rpx solid #fff3;
|
||||
box-sizing: border-box;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.target-choosen {
|
||||
color: #fed847;
|
||||
border: 4rpx solid #fed847;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { ref, watch, onMounted, computed } from "vue";
|
||||
const props = defineProps({
|
||||
isRed: {
|
||||
type: Boolean,
|
||||
@@ -10,7 +10,7 @@ const props = defineProps({
|
||||
default: () => [],
|
||||
},
|
||||
currentShooterId: {
|
||||
type: [Number, String],
|
||||
type: Number,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
@@ -30,35 +30,31 @@ const getPos = (id) => {
|
||||
return sort * 40;
|
||||
};
|
||||
|
||||
const syncPlayers = () => {
|
||||
const nextPlayers = {};
|
||||
const shooterId = props.currentShooterId;
|
||||
const shooterIndex = props.team.findIndex(
|
||||
(p) => String(p?.id) === String(shooterId)
|
||||
);
|
||||
const nextTeam = [...props.team];
|
||||
|
||||
currentTeam.value = !!shooterId && shooterIndex >= 0;
|
||||
firstName.value = "";
|
||||
|
||||
if (currentTeam.value) {
|
||||
const target = nextTeam.splice(shooterIndex, 1)[0];
|
||||
if (target) {
|
||||
nextTeam.unshift(target);
|
||||
firstName.value = target.name || "";
|
||||
}
|
||||
}
|
||||
|
||||
nextTeam.forEach((p, index) => {
|
||||
if (p?.id) nextPlayers[p.id] = { sort: index, ...p };
|
||||
onMounted(() => {
|
||||
props.team.forEach((p, index) => {
|
||||
players.value[p.id] = { sort: index, ...p };
|
||||
});
|
||||
});
|
||||
players.value = nextPlayers;
|
||||
};
|
||||
|
||||
watch(
|
||||
[() => props.team, () => props.currentShooterId],
|
||||
syncPlayers,
|
||||
{ immediate: true, deep: true }
|
||||
() => props.currentShooterId,
|
||||
(newVal) => {
|
||||
if (!newVal) return;
|
||||
const index = props.team.findIndex((p) => p.id === newVal);
|
||||
currentTeam.value = index >= 0;
|
||||
if (index >= 0) {
|
||||
const newPlayers = [...props.team];
|
||||
const target = newPlayers.splice(index, 1)[0];
|
||||
if (target) {
|
||||
newPlayers.unshift(target);
|
||||
firstName.value = target.name;
|
||||
newPlayers.forEach((p, index) => {
|
||||
players.value[p.id] = { sort: index, ...p };
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -74,7 +70,7 @@ watch(
|
||||
/>
|
||||
<view
|
||||
v-for="(item, index) in team"
|
||||
:key="item.id || index"
|
||||
:key="index"
|
||||
class="player"
|
||||
:style="{
|
||||
width: (isFirst(item.id) ? 80 : 60) + 'rpx',
|
||||
|
||||
@@ -5,17 +5,11 @@ import BowPower from "@/components/BowPower.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import { simulShootAPI } from "@/apis";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import {
|
||||
getDistanceCheckAudioKey,
|
||||
getDistanceCheckText,
|
||||
getShootValidation,
|
||||
} from "@/util";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, device } = storeToRefs(store);
|
||||
const emit = defineEmits(["passed"]);
|
||||
const props = defineProps({
|
||||
guide: {
|
||||
type: Boolean,
|
||||
@@ -25,98 +19,46 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: 15,
|
||||
},
|
||||
targetType: {
|
||||
type: [Number, String],
|
||||
default: 40,
|
||||
},
|
||||
autoStart: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const arrow = ref({});
|
||||
const distance = ref(0);
|
||||
const statusText = ref("");
|
||||
const debugInfo = ref("");
|
||||
const showsimul = ref(false);
|
||||
const count = ref(props.count);
|
||||
const count = ref(15);
|
||||
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) => {
|
||||
count.value = Math.round(value);
|
||||
};
|
||||
onMounted(() => {
|
||||
audioManager.play("请射箭!测试站距与靶纸");
|
||||
if (props.isBattle) {
|
||||
audioManager.play("请射箭测试距离");
|
||||
timer.value = setInterval(() => {
|
||||
count.value -= 1;
|
||||
if (count.value < 0) clearInterval(timer.value);
|
||||
if (count.value > 0) count.value -= 1;
|
||||
else clearInterval(timer.value);
|
||||
}, 1000);
|
||||
}
|
||||
uni.$on("update-timer", updateTimer);
|
||||
uni.$on("audioEnded", onAudioEnded);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
clearAutoStartTimer();
|
||||
uni.$off("update-timer", updateTimer);
|
||||
uni.$off("audioEnded", onAudioEnded);
|
||||
});
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (Array.isArray(msg)) return;
|
||||
if (msg.type === MESSAGETYPESV2.TestDistance) {
|
||||
if (autoStartPending.value || autoStartTriggered.value) return;
|
||||
const rawDistance = Number(msg.shootData?.distance ?? msg.shootData?.dst);
|
||||
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);
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
arrow.value = msg.target;
|
||||
distance.value = Number((msg.target.dst / 100).toFixed(2));
|
||||
debugInfo.value = msg.target;
|
||||
audioManager.play("距离合格");
|
||||
} else if (msg.constructor === MESSAGETYPES.InvalidShot) {
|
||||
distance.value = Number((msg.target.dst / 100).toFixed(2));
|
||||
audioManager.play("距离不足");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const simulShoot = async () => {
|
||||
if (device.value.deviceId) {
|
||||
await simulShootAPI(
|
||||
device.value.deviceId,
|
||||
undefined,
|
||||
undefined,
|
||||
props.targetType
|
||||
);
|
||||
}
|
||||
if (device.value.deviceId) await simulShootAPI(device.value.deviceId);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
@@ -154,11 +96,13 @@ onBeforeUnmount(() => {
|
||||
模拟射箭
|
||||
</button>
|
||||
<view class="warnning-text">
|
||||
<block v-if="statusText">
|
||||
<text>{{ statusText }}</text>
|
||||
<block v-if="distance > 0">
|
||||
<text>当前距离{{ distance }}米</text>
|
||||
<text v-if="distance >= 5">已达到距离要求</text>
|
||||
<text v-else>请调整站位</text>
|
||||
</block>
|
||||
<block v-else>
|
||||
<text>请射箭,测试站距与靶纸</text>
|
||||
<text>请射箭,测试站距</text>
|
||||
</block>
|
||||
</view>
|
||||
<view class="user-row">
|
||||
@@ -167,13 +111,12 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="isBattle" class="ready-timer">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/test-tip.png" mode="widthFix" />
|
||||
<view v-if="count >= 0">
|
||||
<text>距离正式比赛还有</text>
|
||||
<image src="../static/test-tip.png" mode="widthFix" />
|
||||
<view>
|
||||
<text>具体正式比赛还有</text>
|
||||
<text>{{ count }}</text>
|
||||
<text>秒</text>
|
||||
</view>
|
||||
<view v-else> 进入中... </view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -9,13 +9,11 @@ const props = defineProps({
|
||||
const show = ref(false);
|
||||
const count = ref(props.countdown);
|
||||
const timer = ref(null);
|
||||
const showTimer = ref(null);
|
||||
const updateTimer = (value) => {
|
||||
count.value = Math.round(value);
|
||||
};
|
||||
onMounted(() => {
|
||||
showTimer.value = setTimeout(() => {
|
||||
showTimer.value = null;
|
||||
setTimeout(() => {
|
||||
show.value = true;
|
||||
timer.value = setInterval(() => {
|
||||
if (count.value === 0) {
|
||||
@@ -29,10 +27,7 @@ onMounted(() => {
|
||||
uni.$on("update-timer", updateTimer);
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
if (showTimer.value) clearTimeout(showTimer.value);
|
||||
showTimer.value = null;
|
||||
if (timer.value) clearInterval(timer.value);
|
||||
timer.value = null;
|
||||
uni.$off("update-timer", updateTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
<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,10 +10,6 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
fullNickname: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
onSignin: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
@@ -23,8 +19,6 @@ const nextLvlPoints = ref(0);
|
||||
const containerWidth = computed(() =>
|
||||
props.showRank ? "72%" : "calc(100% - 15px)"
|
||||
);
|
||||
const isSVip = computed(() => user.value.sVip === true);
|
||||
const isVip = computed(() => user.value.vip === true && !isSVip.value);
|
||||
const toUserPage = () => {
|
||||
// 获取当前页面路径
|
||||
const pages = getCurrentPages();
|
||||
@@ -65,13 +59,7 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view
|
||||
:class="[
|
||||
'container',
|
||||
fullNickname ? 'container--full-nickname' : '',
|
||||
]"
|
||||
:style="{ width: containerWidth }"
|
||||
>
|
||||
<view class="container" :style="{ width: containerWidth }">
|
||||
<block v-if="user.id">
|
||||
<Avatar
|
||||
:rankLvl="user.rankLvl"
|
||||
@@ -81,23 +69,12 @@ watch(
|
||||
/>
|
||||
<view class="user-details" @click="toUserPage">
|
||||
<view class="user-name">
|
||||
<view
|
||||
:class="[
|
||||
'member-nickname',
|
||||
isVip ? 'member-nickname--vip' : '',
|
||||
isSVip ? 'member-nickname--svip' : '',
|
||||
]"
|
||||
>
|
||||
<text class="member-nickname__text">{{ user.nickName }}</text>
|
||||
<text v-if="isSVip" class="member-nickname__shine">{{
|
||||
user.nickName
|
||||
}}</text>
|
||||
</view>
|
||||
<!-- <image
|
||||
<text>{{ user.nickName }}</text>
|
||||
<image
|
||||
class="user-name-image"
|
||||
src="../static/vip1.png"
|
||||
mode="widthFix"
|
||||
/> -->
|
||||
/>
|
||||
</view>
|
||||
<view class="user-stats">
|
||||
<text class="level-tag level-tag-first">段位积分</text>
|
||||
@@ -171,26 +148,12 @@ watch(
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.user-name .member-nickname {
|
||||
max-width: 180rpx;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.user-name > text:first-child {
|
||||
font-size: 13px;
|
||||
max-width: 180rpx;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-name-image {
|
||||
|
||||
@@ -54,23 +54,23 @@ onBeforeUnmount(() => {
|
||||
<view v-if="showRank" class="up-rank">
|
||||
<image :src="user.avatar || '../static/user-icon.png'" mode="widthFix" />
|
||||
<image :src="nextRankImage" mode="widthFix" />
|
||||
<image class="bg-effect" src="https://static.shelingxingqiu.com/shootmini/static/shining-bg.png" mode="widthFix" />
|
||||
<image class="bg-effect" src="../static/shining-bg.png" mode="widthFix" />
|
||||
<image
|
||||
class="bg-effect"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/gold-shining.png"
|
||||
src="../static/gold-shining.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="showGrade" class="up-grade">
|
||||
<image
|
||||
class="scale-in"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/user-upgrade.png"
|
||||
src="../static/user-upgrade.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image class="bg-effect" src="https://static.shelingxingqiu.com/shootmini/static/shining-bg.png" mode="widthFix" />
|
||||
<image class="bg-effect" src="../static/shining-bg.png" mode="widthFix" />
|
||||
<image
|
||||
class="bg-effect"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/gold-shining.png"
|
||||
src="../static/gold-shining.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
|
||||
+5
-23
@@ -1,7 +1,6 @@
|
||||
export const MESSAGETYPES = {
|
||||
ShootSyncMeArrowID: parseInt("0x789b6b0d"), // 2023451405
|
||||
ShootSyncMePracticeID: parseInt("0xD88AE05E"), // 3632980062
|
||||
ShootSyncBattleCreateMatchLinkID: parseInt("0xF86FF466"), // 2538869862
|
||||
WaitForAllReady: parseInt("0x615C13BE"), // 1633424318
|
||||
AllReady: parseInt("0x1CCB49FD"), // 483084797
|
||||
MeleeAllReady: parseInt("0x37132BD5"), // 924003285
|
||||
@@ -29,20 +28,6 @@ export const MESSAGETYPES = {
|
||||
DeviceOnline: 4168086626,
|
||||
DeviceOffline: 4168086627,
|
||||
SomeoneIsReady: 4168086628,
|
||||
DeviceCharging: 4168086631, // 设备充电中
|
||||
};
|
||||
|
||||
export const MESSAGETYPESV2 = {
|
||||
AboutToStart: 1,
|
||||
BattleStart: 2,
|
||||
ToSomeoneShoot: 3,
|
||||
ShootResult: 4,
|
||||
NewRound: 5,
|
||||
BattleEnd: 6,
|
||||
HalfRest: 7,
|
||||
TestDistance: 8,
|
||||
MatchSuccess: 9,
|
||||
InvalidShot: 10,
|
||||
};
|
||||
|
||||
export const topThreeColors = ["#FFD947", "#D2D2D2", "#FFA515"];
|
||||
@@ -51,10 +36,7 @@ export const getMessageTypeName = (id) => {
|
||||
for (let key in MESSAGETYPES) {
|
||||
if (MESSAGETYPES[key] === id) return key;
|
||||
}
|
||||
for (let key in MESSAGETYPESV2) {
|
||||
if (MESSAGETYPESV2[key] === id) return key;
|
||||
}
|
||||
return id;
|
||||
return null;
|
||||
};
|
||||
|
||||
export const roundsName = {
|
||||
@@ -120,7 +102,7 @@ export const getBattleResultTips = (
|
||||
) => {
|
||||
const getRandomIndex = (len) => Math.floor(Math.random() * len);
|
||||
if (gameMode === 1) {
|
||||
if (mode <= 3) {
|
||||
if (mode === 1) {
|
||||
if (win) {
|
||||
const tests = [
|
||||
"https://static.shelingxingqiu.com/attachment/2025-08-01/dbqq1fglywucyoh9zn.png",
|
||||
@@ -142,7 +124,7 @@ export const getBattleResultTips = (
|
||||
];
|
||||
return tests[getRandomIndex(3)];
|
||||
}
|
||||
} else {
|
||||
} else if (mode === 2) {
|
||||
if (rank <= 3) {
|
||||
const tests = [
|
||||
"好成绩!全国排位赛等着你!",
|
||||
@@ -154,7 +136,7 @@ export const getBattleResultTips = (
|
||||
}
|
||||
}
|
||||
} else if (gameMode === 2) {
|
||||
if (mode <= 3) {
|
||||
if (mode === 1) {
|
||||
if (win) {
|
||||
const tests = [
|
||||
"https://static.shelingxingqiu.com/attachment/2025-08-01/dbqq1fgtb29jbdus4g.png",
|
||||
@@ -176,7 +158,7 @@ export const getBattleResultTips = (
|
||||
];
|
||||
return tests[getRandomIndex(3)];
|
||||
}
|
||||
} else {
|
||||
} else if (mode === 2) {
|
||||
if (score > 0) {
|
||||
const tests = [
|
||||
"王者一定属于你!",
|
||||
|
||||
-15
@@ -1,26 +1,11 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import App from './App.vue'
|
||||
import audioManager from './audioManager'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
app.use(pinia)
|
||||
|
||||
/**
|
||||
* 全局点击音效工具函数,用于在任意按钮/元素点击时自动播放音效。
|
||||
* 用法:@click="$clickSound(handler)" 或 @click="$clickSound(() => doSomething())"
|
||||
* @param {Function} handler - 原始点击回调函数(可选,点击时直接调用)
|
||||
* @param {string} [soundKey='点击按钮'] - audioManager 中的音效 key
|
||||
*/
|
||||
app.config.globalProperties.$clickSound = (handler, soundKey = '点击按钮') => {
|
||||
audioManager.play(soundKey);
|
||||
if (typeof handler === 'function') handler();
|
||||
};
|
||||
|
||||
return {
|
||||
app
|
||||
}
|
||||
|
||||
+3
-256
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "shoot-miniprograms",
|
||||
"appid" : "__UNI__B03E251",
|
||||
"appid": "",
|
||||
"description": "",
|
||||
"versionName": "1.0.0",
|
||||
"versionCode": "100",
|
||||
@@ -40,9 +40,7 @@
|
||||
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
|
||||
]
|
||||
},
|
||||
"ios" : {
|
||||
"dSYMs" : false
|
||||
},
|
||||
"ios": {},
|
||||
"sdkConfigs": {}
|
||||
}
|
||||
},
|
||||
@@ -53,253 +51,6 @@
|
||||
"quickapp": {},
|
||||
"mp-weixin": {
|
||||
"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": {
|
||||
"urlCheck": false,
|
||||
"minified": true,
|
||||
@@ -311,11 +62,7 @@
|
||||
"usingComponents": true,
|
||||
"darkmode": true,
|
||||
"themeLocation": "theme.json",
|
||||
"permission" : {
|
||||
"scope.userLocation": {
|
||||
"desc": "用于扫描附近 WiFi,完成设备 OTA 升级网络连接"
|
||||
}
|
||||
},
|
||||
"permission": {},
|
||||
"requiredPrivateInfos": ["getLocation", "chooseLocation"]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+26
-109
@@ -3,9 +3,6 @@
|
||||
{
|
||||
"path": "pages/index"
|
||||
},
|
||||
{
|
||||
"path": "pages/org-bind"
|
||||
},
|
||||
{
|
||||
"path": "pages/friend-battle"
|
||||
},
|
||||
@@ -21,6 +18,9 @@
|
||||
{
|
||||
"path": "pages/audio-test"
|
||||
},
|
||||
{
|
||||
"path": "pages/calibration"
|
||||
},
|
||||
{
|
||||
"path": "pages/about-us"
|
||||
},
|
||||
@@ -30,14 +30,11 @@
|
||||
"navigationBarTitleText": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-battle"
|
||||
},
|
||||
{
|
||||
"path": "pages/battle-result"
|
||||
},
|
||||
{
|
||||
"path": "pages/friend-battle-result"
|
||||
"path": "pages/team-battle"
|
||||
},
|
||||
{
|
||||
"path": "pages/point-book-edit"
|
||||
@@ -57,9 +54,24 @@
|
||||
{
|
||||
"path": "pages/match-page"
|
||||
},
|
||||
{
|
||||
"path": "pages/my-device"
|
||||
},
|
||||
{
|
||||
"path": "pages/device-intro"
|
||||
},
|
||||
{
|
||||
"path": "pages/user"
|
||||
},
|
||||
{
|
||||
"path": "pages/orders"
|
||||
},
|
||||
{
|
||||
"path": "pages/order-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/be-vip"
|
||||
},
|
||||
{
|
||||
"path": "pages/grade-intro"
|
||||
},
|
||||
@@ -90,9 +102,15 @@
|
||||
{
|
||||
"path": "pages/rank-list"
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-match"
|
||||
},
|
||||
{
|
||||
"path": "pages/match-detail"
|
||||
},
|
||||
{
|
||||
"path": "pages/team-bow-data"
|
||||
},
|
||||
{
|
||||
"path": "pages/melee-bow-data"
|
||||
},
|
||||
@@ -117,106 +135,5 @@
|
||||
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||||
}
|
||||
},
|
||||
"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": "my-device"
|
||||
},
|
||||
{
|
||||
"path": "device-qrcode"
|
||||
},
|
||||
{
|
||||
"path": "device-bind-success"
|
||||
},
|
||||
{
|
||||
"path": "device-bind-failure"
|
||||
},
|
||||
{
|
||||
"path": "device-intro"
|
||||
},
|
||||
{
|
||||
"path": "ota-wifi",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "calibration"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"subPackages": []
|
||||
}
|
||||
|
||||
@@ -9,23 +9,17 @@ const playAudio = (key) => {
|
||||
audioManager.play(key);
|
||||
};
|
||||
|
||||
const onAudioLoaded = (key) => {
|
||||
loaded.value = {
|
||||
...loaded.value,
|
||||
[key]: true,
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const loadedAudioKeys = uni.getStorageSync("loadedAudioKeys") || {};
|
||||
loaded.value = loadedAudioKeys;
|
||||
|
||||
uni.$on("audioLoaded", onAudioLoaded);
|
||||
void audioManager.initAudios();
|
||||
uni.$on("audioLoaded", (key) => {
|
||||
loaded.value[key] = true;
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
uni.$off("audioLoaded", onAudioLoaded);
|
||||
uni.$off("audioLoaded");
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -46,10 +40,8 @@ onBeforeUnmount(() => {
|
||||
</view>
|
||||
<view v-for="key in Object.keys(audioFils)" :key="key">
|
||||
<text>{{ key }}</text>
|
||||
<text>{{ loaded[key] ? "已加载" : "未加载" }}</text>
|
||||
<button hover-class="none" @click="playAudio(key)">
|
||||
{{ loaded[key] ? "播放" : "加载并播放" }}
|
||||
</button>
|
||||
<text v-if="!loaded[key]">未加载</text>
|
||||
<button v-else hover-class="none" @click="playAudio(key)">播放</button>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
|
||||
+111
-76
@@ -1,10 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import UserUpgrade from "@/components/UserUpgrade.vue";
|
||||
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||
import { getBattleAPI } from "@/apis";
|
||||
import { getGameAPI } from "@/apis";
|
||||
import { topThreeColors, getBattleResultTips } from "@/constants";
|
||||
import audioManager from "@/audioManager";
|
||||
import useStore from "@/store";
|
||||
@@ -17,10 +16,14 @@ const ifWin = ref(false);
|
||||
const data = ref({});
|
||||
const totalPoints = ref(0);
|
||||
const rank = ref(0);
|
||||
const players = ref([]);
|
||||
|
||||
function exit() {
|
||||
if (data.value.roomId) {
|
||||
const battleInfo = uni.getStorageSync("last-battle");
|
||||
if (battleInfo && battleInfo.roomId) {
|
||||
uni.redirectTo({
|
||||
url: `/pages/battle-room?roomNumber=${battleInfo.roomId}`,
|
||||
});
|
||||
} else if (data.value.roomId) {
|
||||
uni.redirectTo({
|
||||
url: `/pages/battle-room?roomNumber=${data.value.roomId}`,
|
||||
});
|
||||
@@ -30,96 +33,129 @@ function exit() {
|
||||
}
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (!options.battleId) return;
|
||||
const myId = user.value.id;
|
||||
const result = await getBattleAPI(options.battleId || "60049406950510592");
|
||||
data.value = result;
|
||||
if (result.winTeam) {
|
||||
ifWin.value = result.teams[result.winTeam].players.some(
|
||||
(p) => p.id === myId
|
||||
if (options.battleId) {
|
||||
const result = await getGameAPI(
|
||||
options.battleId || "BATTLE-1758270367040321900-868"
|
||||
);
|
||||
}
|
||||
if (result.mode <= 3) {
|
||||
audioManager.play(ifWin.value ? "胜利" : "失败");
|
||||
} else {
|
||||
players.value = result.resultList.map((item, index) => {
|
||||
const plist = result.teams[0] ? result.teams[0].players : [];
|
||||
const p = plist.find((p) => p.id === item.userId);
|
||||
if (p.id === user.value.id) {
|
||||
totalPoints.value = p.score;
|
||||
rank.value = index + 1;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
rank: index + 1,
|
||||
name: p.name,
|
||||
avatar: p.avatar || "",
|
||||
data.value = {
|
||||
...result,
|
||||
battleMode: result.gameMode,
|
||||
};
|
||||
});
|
||||
if (rank.value <= players.value * 0.3) {
|
||||
audioManager.play("胜利");
|
||||
if (result.mode === 1) {
|
||||
data.value.redPlayers = Object.values(result.redPlayers);
|
||||
data.value.bluePlayers = Object.values(result.bluePlayers);
|
||||
if (result.redPlayers[myId]) {
|
||||
totalPoints.value = result.redPlayers[myId].totalScore;
|
||||
data.value.myTeam = result.redPlayers[myId].team;
|
||||
ifWin.value = result.winner === 0;
|
||||
}
|
||||
if (result.bluePlayers[myId]) {
|
||||
totalPoints.value = result.bluePlayers[myId].totalScore;
|
||||
data.value.myTeam = result.bluePlayers[myId].team;
|
||||
ifWin.value = result.winner === 1;
|
||||
}
|
||||
}
|
||||
if (result.mode === 2) {
|
||||
data.value.playerStats = result.players.map((p) => ({
|
||||
...p,
|
||||
id: p.playerId,
|
||||
}));
|
||||
const mine = result.players.find((p) => p.playerId === myId);
|
||||
if (mine) totalPoints.value = mine.totalScore;
|
||||
rank.value = result.players.findIndex((p) => p.playerId === myId) + 1;
|
||||
}
|
||||
} else {
|
||||
const battleInfo = uni.getStorageSync("last-battle");
|
||||
if (!battleInfo) return;
|
||||
data.value = {
|
||||
mvps: [],
|
||||
...battleInfo,
|
||||
};
|
||||
if (battleInfo.mode === 1) {
|
||||
battleInfo.playerStats.forEach((p) => {
|
||||
if (p.team === 1) data.value.bluePlayers = [p];
|
||||
if (p.team === 0) data.value.redPlayers = [p];
|
||||
if (p.mvp) data.value.mvps.push(p);
|
||||
});
|
||||
data.value.mvps.sort((a, b) => b.totalRings - a.totalRings);
|
||||
}
|
||||
rank.value = 0;
|
||||
const mine = battleInfo.playerStats.find((p, index) => {
|
||||
rank.value = index + 1;
|
||||
return p.id === myId;
|
||||
});
|
||||
if (mine) {
|
||||
data.value.myTeam = mine.team;
|
||||
totalPoints.value = mine.totalScore;
|
||||
if (battleInfo.mode === 1) {
|
||||
ifWin.value = mine.team === battleInfo.winner;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.value.mode === 1) {
|
||||
audioManager.play(ifWin.value ? "胜利" : "失败");
|
||||
} else if (data.value.mode === 2) {
|
||||
if (data.value.battleMode === 1) {
|
||||
if (rank.value <= data.value.playerStats.length * 0.3) {
|
||||
audioManager.play("胜利");
|
||||
}
|
||||
} else if (data.value.battleMode === 2) {
|
||||
if (totalPoints.value > 0) {
|
||||
audioManager.play("胜利");
|
||||
} else if (totalPoints.value < 0) {
|
||||
audioManager.play("失败");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const myTeam = computed(() => {
|
||||
const teams = data.value.teams;
|
||||
if (teams && teams.length) {
|
||||
if (teams[1].players.some((p) => p.id === user.value.id)) return 1;
|
||||
}
|
||||
return 2;
|
||||
});
|
||||
|
||||
const checkBowData = () => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/match-detail?battleId=${data.value.matchId}`,
|
||||
url: `/pages/match-detail?id=${data.value.id}`,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="container">
|
||||
<block v-if="data.mode <= 3">
|
||||
<block v-if="data.mode === 1">
|
||||
<view class="header-team" :style="{ marginTop: '25%' }">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/battle-result.png" mode="widthFix" />
|
||||
<view class="header-solo" v-if="data.mode === 1">
|
||||
<image src="../static/battle-result.png" mode="widthFix" />
|
||||
<view class="header-solo" v-if="data.teamSize === 2">
|
||||
<text
|
||||
:style="{
|
||||
background:
|
||||
data.winTeam === 1
|
||||
data.winner === 1
|
||||
? 'linear-gradient(270deg, #3597ff 0%, rgba(0,0,0,0) 100%);'
|
||||
: 'linear-gradient(270deg, #fd4444 0%, rgba(0, 0, 0, 0) 100%)',
|
||||
}"
|
||||
>{{ data.winTeam === 1 ? "蓝队" : "红队" }}获胜</text
|
||||
>{{ data.winner === 1 ? "蓝队" : "红队" }}获胜</text
|
||||
>
|
||||
<Avatar
|
||||
:size="32"
|
||||
:src="
|
||||
data.winTeam === 1
|
||||
? data.teams[1].players[0].avatar
|
||||
: data.teams[2].players[0].avatar
|
||||
data.winner === 1
|
||||
? data.bluePlayers[0].avatar
|
||||
: data.redPlayers[0].avatar
|
||||
"
|
||||
:borderColor="data.winTeam === 1 ? '#5FADFF' : '#FF5656'"
|
||||
:borderColor="data.winner === 1 ? '#5FADFF' : '#FF5656'"
|
||||
mode="widthFix"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<view class="header-mvp" v-if="data.mode === 2 || data.mode === 3">
|
||||
<view class="header-mvp" v-if="data.teamSize !== 2">
|
||||
<image
|
||||
:src="`https://static.shelingxingqiu.com/shootmini/static/${data.winTeam === 1 ? 'blue' : 'red'}-team-win.png`"
|
||||
:src="`../static/${data.winner === 1 ? 'blue' : 'red'}-team-win.png`"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view
|
||||
:style="{
|
||||
transform: `translateY(50px) rotate(-${
|
||||
5 + (data.mvp || []).length
|
||||
}deg)`,
|
||||
transform: `translateY(50px) rotate(-${5 + data.mvps.length}deg)`,
|
||||
}"
|
||||
>
|
||||
<view v-if="data.mvp && data.mvp.player_match_result.total_ring">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/title-mvp.png" mode="widthFix" />
|
||||
<view v-if="data.mvps && data.mvps[0].totalRings">
|
||||
<image src="../static/title-mvp.png" mode="widthFix" />
|
||||
<text
|
||||
>斩获<text
|
||||
:style="{
|
||||
@@ -128,22 +164,22 @@ const checkBowData = () => {
|
||||
margin: '0 3px',
|
||||
fontWeight: '600',
|
||||
}"
|
||||
>{{ data.mvp.player_match_result.total_ring }}</text
|
||||
>{{ data.mvps[0].totalRings }}</text
|
||||
>环</text
|
||||
>
|
||||
</view>
|
||||
<view v-if="data.mvp && data.mvp.length">
|
||||
<view v-for="(player, index) in data.mvp" :key="index">
|
||||
<view v-if="data.mvps && data.mvps.length">
|
||||
<view v-for="(player, index) in data.mvps" :key="index">
|
||||
<view class="team-avatar">
|
||||
<Avatar
|
||||
:src="player.avatar"
|
||||
:size="40"
|
||||
:borderColor="myTeam === 1 ? '#5fadff' : '#ff6060'"
|
||||
:borderColor="data.myTeam === 1 ? '#5fadff' : '#ff6060'"
|
||||
/>
|
||||
<text
|
||||
v-if="player.id === user.id"
|
||||
:style="{
|
||||
backgroundColor: myTeam === 1 ? '#5fadff' : '#ff6060',
|
||||
backgroundColor: data.myTeam === 1 ? '#5fadff' : '#ff6060',
|
||||
}"
|
||||
>自己</text
|
||||
>
|
||||
@@ -154,7 +190,7 @@ const checkBowData = () => {
|
||||
</view>
|
||||
</view>
|
||||
<view class="battle-winner">
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/shining-bg.png" mode="widthFix" />
|
||||
<image src="../static/shining-bg.png" mode="widthFix" />
|
||||
<image
|
||||
:src="ifWin ? '../static/you-win.png' : '../static/you-lost.png'"
|
||||
mode="widthFix"
|
||||
@@ -162,7 +198,7 @@ const checkBowData = () => {
|
||||
/>
|
||||
<image
|
||||
:src="
|
||||
getBattleResultTips(data.way, data.mode, {
|
||||
getBattleResultTips(data.battleMode, data.mode, {
|
||||
win: ifWin,
|
||||
})
|
||||
"
|
||||
@@ -171,20 +207,20 @@ const checkBowData = () => {
|
||||
/>
|
||||
</view>
|
||||
</block>
|
||||
<block v-else>
|
||||
<block v-if="data.mode === 2">
|
||||
<view class="header-melee">
|
||||
<view />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/battle-result.png" mode="widthFix" />
|
||||
<image src="../static/battle-result.png" mode="widthFix" />
|
||||
<view />
|
||||
</view>
|
||||
<view
|
||||
class="players"
|
||||
:style="{
|
||||
height: `${Math.max(players.length > 5 ? '330' : '300')}px`,
|
||||
height: `${Math.max(data.playerStats.length > 5 ? '330' : '300')}px`,
|
||||
}"
|
||||
>
|
||||
<view
|
||||
v-for="(player, index) in players"
|
||||
v-for="(player, index) in data.playerStats"
|
||||
:key="index"
|
||||
:style="{
|
||||
border: player.id === user.id ? '1px solid #B04630' : 'none',
|
||||
@@ -239,7 +275,7 @@ const checkBowData = () => {
|
||||
<text>{{ getLvlName(player.rank_lvl) }}</text>
|
||||
</view>
|
||||
<text
|
||||
><text :style="{ color: '#fff' }">{{ player.totalRing }}</text>
|
||||
><text :style="{ color: '#fff' }">{{ player.totalRings }}</text>
|
||||
环</text
|
||||
>
|
||||
</view>
|
||||
@@ -247,36 +283,36 @@ const checkBowData = () => {
|
||||
</block>
|
||||
<view
|
||||
class="battle-e"
|
||||
:style="{ marginTop: data.mode > 3 ? '20px' : '20vw' }"
|
||||
:style="{ marginTop: data.mode === 2 ? '20px' : '20vw' }"
|
||||
>
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/row-yellow-bg.png" mode="widthFix" />
|
||||
<image src="../static/row-yellow-bg.png" mode="widthFix" />
|
||||
<view class="team-avatar">
|
||||
<Avatar
|
||||
:src="user.avatar"
|
||||
:size="40"
|
||||
:borderColor="myTeam === 1 ? '#5fadff' : '#ff6060'"
|
||||
:borderColor="data.myTeam === 1 ? '#5fadff' : '#ff6060'"
|
||||
/>
|
||||
<text
|
||||
:style="{ backgroundColor: '#5fadff' }"
|
||||
v-if="data.mode <= 3 && myTeam === 1"
|
||||
v-if="data.mode === 1 && data.myTeam === 1"
|
||||
>蓝队</text
|
||||
>
|
||||
<text
|
||||
:style="{ backgroundColor: '#ff6060' }"
|
||||
v-if="data.mode <= 3 && myTeam === 2"
|
||||
v-if="data.mode === 1 && data.myTeam === 0"
|
||||
>红队</text
|
||||
>
|
||||
</view>
|
||||
<text v-if="data.way === 1">
|
||||
<text v-if="data.battleMode === 1">
|
||||
你的经验 {{ totalPoints > 0 ? "+" + totalPoints : totalPoints }}
|
||||
</text>
|
||||
<text v-if="data.way === 2">
|
||||
<text v-if="data.battleMode === 2">
|
||||
你的积分 {{ totalPoints > 0 ? "+" + totalPoints : totalPoints }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="data.mode > 3" class="description">
|
||||
<text v-if="data.mode === 2" class="description">
|
||||
{{
|
||||
getBattleResultTips(data.way, data.mode, {
|
||||
getBattleResultTips(data.battleMode, data.mode, {
|
||||
win: ifWin,
|
||||
score: totalPoints,
|
||||
rank,
|
||||
@@ -288,7 +324,6 @@ const checkBowData = () => {
|
||||
<view @click="exit">返回</view>
|
||||
</view>
|
||||
<UserUpgrade />
|
||||
<DeviceChargingDialog />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
|
||||
+287
-738
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
<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,10 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onShow, onHide } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
|
||||
import { aimRenewAPI, laserAimAPI, laserCloseAPI } from "@/apis";
|
||||
import { laserAimAPI, laserCloseAPI } from "@/apis";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
// import audioManager from "@/audioManager";
|
||||
|
||||
@@ -24,56 +23,8 @@ const guides = [
|
||||
];
|
||||
|
||||
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 () => {
|
||||
stopAimSession(false);
|
||||
await laserCloseAPI();
|
||||
uni.navigateBack();
|
||||
};
|
||||
@@ -88,26 +39,14 @@ function onReceiveMessage(messages = []) {
|
||||
});
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
pageVisible = true;
|
||||
const sessionVersion = ++aimSessionVersion;
|
||||
if (pageMounted) void openAimSession(sessionVersion);
|
||||
});
|
||||
|
||||
onHide(() => {
|
||||
stopAimSession();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
uni.$on("socket-inbox", onReceiveMessage);
|
||||
pageMounted = true;
|
||||
if (pageVisible) void openAimSession(aimSessionVersion);
|
||||
await laserAimAPI();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pageMounted = false;
|
||||
stopAimSession();
|
||||
onBeforeUnmount(async () => {
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
await laserCloseAPI();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
<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>
|
||||
@@ -1,37 +0,0 @@
|
||||
<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>
|
||||
@@ -1,86 +0,0 @@
|
||||
<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>
|
||||
@@ -1,50 +0,0 @@
|
||||
<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>
|
||||
@@ -1,188 +0,0 @@
|
||||
<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>
|
||||
@@ -1,54 +0,0 @@
|
||||
<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>
|
||||
@@ -1,56 +0,0 @@
|
||||
<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>
|
||||
@@ -1,90 +0,0 @@
|
||||
<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>
|
||||
@@ -1,97 +0,0 @@
|
||||
<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>
|
||||
@@ -1,69 +0,0 @@
|
||||
<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>
|
||||
@@ -1,22 +0,0 @@
|
||||
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",
|
||||
},
|
||||
];
|
||||
@@ -1,130 +0,0 @@
|
||||
<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>
|
||||
@@ -1,130 +0,0 @@
|
||||
<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>
|
||||
@@ -1,216 +0,0 @@
|
||||
<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>
|
||||
@@ -1,167 +0,0 @@
|
||||
<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>
|
||||
@@ -1,144 +0,0 @@
|
||||
<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>
|
||||
@@ -1,133 +0,0 @@
|
||||
<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,20 +1,19 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import DeviceChargingDialog from "@/components/DeviceChargingDialog.vue";
|
||||
|
||||
import { capsuleHeight } from "@/util";
|
||||
|
||||
const images = [
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_01.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_02.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_03.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_04.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_05.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_06.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_07.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_08.jpg",
|
||||
"https://static.shelingxingqiu.com/mall/images/mall_09.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmf6yitekatwe.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmi475gqdtrvx.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmgy8ej5wuap5.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmg6y7nveaadv.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-12-04/depguhlqg9zxastyn3.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-12-04/depguhlfr041aedqmb.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-12-04/depguhlpnlyxndnor5.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-09-04/dcjmxsmg68a8mezgzx.jpg",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-10-14/ddht51a3hiyw7ueli4.jpg",
|
||||
];
|
||||
|
||||
const addBg = ref(false);
|
||||
@@ -34,11 +33,11 @@ const onScrollView = (e) => {
|
||||
>
|
||||
<image
|
||||
:style="{ opacity: addBg ? 1 : 0 }"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/app-bg.png"
|
||||
src="../static/app-bg.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<navigator open-type="navigateBack">
|
||||
<image class="header-back" src="../../static/back.png" mode="widthFix" />
|
||||
<image class="header-back" src="../static/back.png" mode="widthFix" />
|
||||
</navigator>
|
||||
<text
|
||||
:style="{ opacity: addBg ? 1 : 0, color: '#fff', fontWeight: 'bold' }"
|
||||
@@ -56,7 +55,6 @@ const onScrollView = (e) => {
|
||||
/>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<DeviceChargingDialog />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { bindDeviceAPIV2 } from "@/apis";
|
||||
|
||||
export function useDeviceBinding({
|
||||
token,
|
||||
confirmBindTip,
|
||||
binding,
|
||||
updateDevice,
|
||||
deviceDetails,
|
||||
}) {
|
||||
const showBindFailurePage = () => {
|
||||
uni.hideToast();
|
||||
uni.navigateTo({
|
||||
url: "/pages/device/device-bind-failure",
|
||||
fail: (error) => {
|
||||
console.error("打开绑定失败页失败", error);
|
||||
uni.showToast({ title: "二维码不正确,请重新扫码", icon: "none" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleScan = () => {
|
||||
uni.scanCode({
|
||||
onlyFromCamera: true,
|
||||
scanType: ["qrCode"],
|
||||
success: (result) => {
|
||||
if (!result?.result) {
|
||||
showBindFailurePage();
|
||||
return;
|
||||
}
|
||||
token.value = result.result;
|
||||
confirmBindTip.value = true;
|
||||
},
|
||||
fail: (error) => {
|
||||
const message = String(error?.errMsg || error?.message || "");
|
||||
if (/cancel|取消/i.test(message)) return;
|
||||
showBindFailurePage();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const confirmBind = async () => {
|
||||
if (!token.value || binding.value) return;
|
||||
binding.value = true;
|
||||
try {
|
||||
const result = await bindDeviceAPIV2(token.value);
|
||||
confirmBindTip.value = false;
|
||||
if (result?.binded) {
|
||||
uni.showToast({
|
||||
title: "设备已绑定其他账号,请解绑后再绑定",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const deviceId = String(result?.deviceId || "").trim();
|
||||
const deviceName = String(result?.name || result?.deviceName || "").trim();
|
||||
if (!deviceId || !deviceName) {
|
||||
confirmBindTip.value = false;
|
||||
token.value = "";
|
||||
showBindFailurePage();
|
||||
return;
|
||||
}
|
||||
const applyBoundDevice = () => {
|
||||
updateDevice(deviceId, deviceName);
|
||||
deviceDetails.value = result || {};
|
||||
};
|
||||
uni.navigateTo({
|
||||
url: `/pages/device/device-bind-success?deviceId=${encodeURIComponent(deviceId)}`,
|
||||
success: applyBoundDevice,
|
||||
fail: (navigationError) => {
|
||||
applyBoundDevice();
|
||||
console.error("打开绑定成功页失败", navigationError);
|
||||
uni.showToast({ title: "绑定成功,请返回查看设备", icon: "none" });
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("绑定设备失败", error);
|
||||
confirmBindTip.value = false;
|
||||
token.value = "";
|
||||
showBindFailurePage();
|
||||
} finally {
|
||||
binding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { confirmBind, handleScan, showBindFailurePage };
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
import { computed, ref } from "vue";
|
||||
import {
|
||||
getDeviceDetailAPI,
|
||||
getMyDevicesAPI,
|
||||
unbindDeviceAPI,
|
||||
} from "@/apis";
|
||||
|
||||
export const DEVICE_NAME_STORAGE_KEY = "device_name_overrides";
|
||||
|
||||
export function useDeviceStatus({
|
||||
user,
|
||||
device,
|
||||
deviceStatus,
|
||||
online,
|
||||
updateDevice,
|
||||
clearDevice,
|
||||
unbindDialogVisible,
|
||||
}) {
|
||||
const deviceDetails = ref({});
|
||||
let deviceDetailRequestVersion = 0;
|
||||
|
||||
const isDeviceOnline = computed(
|
||||
() => deviceStatus.value.online === true || online.value === true
|
||||
);
|
||||
const statusText = computed(() => (isDeviceOnline.value ? "已连接" : "未连接"));
|
||||
const statusClass = computed(() =>
|
||||
isDeviceOnline.value ? "device-status--online" : "device-status--offline"
|
||||
);
|
||||
const battery = computed(() => {
|
||||
const rawValue = deviceStatus.value.battery ?? deviceStatus.value.power;
|
||||
if (rawValue === null || rawValue === undefined || rawValue === "") return null;
|
||||
const value = Number(rawValue);
|
||||
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : null;
|
||||
});
|
||||
const batteryText = computed(() =>
|
||||
battery.value === null ? "暂无数据" : `${battery.value}%`
|
||||
);
|
||||
const onlineDurationText = computed(() => {
|
||||
const rawDuration = deviceStatus.value.onlineDuration;
|
||||
if (rawDuration == null || String(rawDuration).trim() === "") return "--";
|
||||
|
||||
const seconds = Number(rawDuration);
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "--";
|
||||
|
||||
// WS 推送秒数,页面按完整分钟展示累计使用时间。
|
||||
const totalMinutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return hours > 0 ? `${hours}小时${minutes}分钟` : `${minutes}分钟`;
|
||||
});
|
||||
const networkType = computed(() =>
|
||||
String(deviceStatus.value.netType ?? "").trim().toLowerCase()
|
||||
);
|
||||
const networkText = computed(() => {
|
||||
const netType = networkType.value;
|
||||
if (netType === "wifi") return "WiFi";
|
||||
if (netType === "4g") return "4G";
|
||||
return isDeviceOnline.value ? "在线" : "未连接";
|
||||
});
|
||||
const maskedDeviceId = computed(() => {
|
||||
const id = String(device.value.deviceId || "");
|
||||
if (!id) return "暂无设备编号";
|
||||
if (id.length <= 3) return id;
|
||||
return `${"*".repeat(Math.min(5, id.length - 3))}${id.slice(-3)}`;
|
||||
});
|
||||
const deviceRows = computed(() => [
|
||||
{ label: "设备型号", value: deviceDetails.value.model || "射灵智能弓" },
|
||||
{ label: "当前电量", value: batteryText.value },
|
||||
{ label: "连接方式", value: networkText.value },
|
||||
{ label: "设备编号", value: maskedDeviceId.value },
|
||||
]);
|
||||
|
||||
const getDeviceNameOverrides = () => {
|
||||
const value = uni.getStorageSync(DEVICE_NAME_STORAGE_KEY);
|
||||
return value && typeof value === "object" ? value : {};
|
||||
};
|
||||
|
||||
const refreshDeviceDetails = async () => {
|
||||
const deviceId = device.value.deviceId;
|
||||
if (!deviceId) return;
|
||||
|
||||
const requestVersion = ++deviceDetailRequestVersion;
|
||||
try {
|
||||
const detailResponse = await getDeviceDetailAPI(deviceId);
|
||||
const detail = detailResponse?.detail || detailResponse?.data?.detail;
|
||||
if (
|
||||
!detail ||
|
||||
typeof detail !== "object" ||
|
||||
requestVersion !== deviceDetailRequestVersion ||
|
||||
device.value.deviceId !== deviceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
deviceDetails.value = {
|
||||
...deviceDetails.value,
|
||||
...detail,
|
||||
deviceModelName: detail.deviceModelName ?? "",
|
||||
bindTime: String(
|
||||
detail.bindTime ?? deviceDetails.value.bindTime ?? ""
|
||||
).trim(),
|
||||
qrCodeUrl: String(
|
||||
detail.qrCodeUrl ?? deviceDetails.value.qrCodeUrl ?? ""
|
||||
).trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
// 实时刷新失败时保留当前页面数据,等待下次通知或页面重新显示。
|
||||
console.log("刷新设备详情失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const syncDeviceBinding = async () => {
|
||||
if (!user.value.id) return;
|
||||
try {
|
||||
const devices = await getMyDevicesAPI();
|
||||
if (Array.isArray(devices?.bindings) && devices.bindings.length > 0) {
|
||||
const currentDevice = devices.bindings[0];
|
||||
const nameOverrides = getDeviceNameOverrides();
|
||||
// 二维码和绑定时间仅取详情接口,绑定列表不能作为这两项的回退数据。
|
||||
let latestDevice = {
|
||||
...currentDevice,
|
||||
deviceModelName: "",
|
||||
bindTime: "",
|
||||
qrCodeUrl: "",
|
||||
};
|
||||
|
||||
try {
|
||||
const detailResponse = await getDeviceDetailAPI(currentDevice.deviceId);
|
||||
const detail = detailResponse?.detail || detailResponse?.data?.detail;
|
||||
if (detail && typeof detail === "object") {
|
||||
latestDevice = {
|
||||
...currentDevice,
|
||||
...detail,
|
||||
deviceModelName: detail.deviceModelName ?? "",
|
||||
bindTime: String(detail.bindTime ?? "").trim(),
|
||||
qrCodeUrl: String(detail.qrCodeUrl ?? "").trim(),
|
||||
deviceName:
|
||||
detail.deviceAlias || detail.deviceName || currentDevice.deviceName,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// 详情接口失败时保留绑定关系,二维码和绑定时间仍保持为空。
|
||||
console.log("获取设备详情失败", error);
|
||||
}
|
||||
|
||||
deviceDetails.value = latestDevice;
|
||||
updateDevice(
|
||||
latestDevice.deviceId,
|
||||
nameOverrides[latestDevice.deviceId] ||
|
||||
latestDevice.deviceAlias ||
|
||||
latestDevice.deviceName ||
|
||||
latestDevice.name ||
|
||||
"我的智能弓"
|
||||
);
|
||||
return;
|
||||
}
|
||||
clearDevice();
|
||||
deviceDetailRequestVersion += 1;
|
||||
deviceDetails.value = {};
|
||||
} catch (error) {
|
||||
console.log("同步设备绑定失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const unbindDevice = async () => {
|
||||
if (!device.value.deviceId) return;
|
||||
try {
|
||||
await unbindDeviceAPI(device.value.deviceId);
|
||||
uni.setStorageSync("calibration", false);
|
||||
clearDevice();
|
||||
deviceDetailRequestVersion += 1;
|
||||
deviceDetails.value = {};
|
||||
unbindDialogVisible.value = false;
|
||||
uni.showToast({ title: "解绑成功", icon: "success" });
|
||||
} catch (error) {
|
||||
console.error("解绑设备失败", error);
|
||||
if (error?.type === "DEVICE_BIND_INVALID") {
|
||||
clearDevice();
|
||||
unbindDialogVisible.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
batteryText,
|
||||
deviceDetails,
|
||||
deviceRows,
|
||||
getDeviceNameOverrides,
|
||||
isDeviceOnline,
|
||||
maskedDeviceId,
|
||||
networkText,
|
||||
networkType,
|
||||
onlineDurationText,
|
||||
refreshDeviceDetails,
|
||||
statusClass,
|
||||
statusText,
|
||||
syncDeviceBinding,
|
||||
unbindDevice,
|
||||
};
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
<script setup>
|
||||
import Container from "@/components/Container.vue";
|
||||
|
||||
// 失败页由设备页进入,返回时优先回到上一页;页面栈异常时兜底回到设备页。
|
||||
const goBackToDevicePage = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device" });
|
||||
};
|
||||
|
||||
// 返回设备页后沿用原有扫码绑定流程,不在结果页重复处理二维码数据。
|
||||
const retryScan = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
success: () => uni.$emit("device-bind-retry-scan"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device?retryScan=1" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="device-bind-failure-page">
|
||||
<Container
|
||||
:bgType="12"
|
||||
bgColor="transparent"
|
||||
:onBack="goBackToDevicePage"
|
||||
headerClass="bind-failure-header"
|
||||
:scroll="false"
|
||||
:usePageScroll="true"
|
||||
>
|
||||
<view class="bind-failure-page">
|
||||
<view class="bind-failure-scene">
|
||||
<view class="bind-failure-hero-wrap">
|
||||
<view class="bind-failure-hero-shadow"></view>
|
||||
<image
|
||||
class="bind-failure-hero"
|
||||
src="../../static/device-assets/device-bind-failure-hero.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="bind-failure-message">
|
||||
<text>二维码不正确,</text>
|
||||
<text>仅支持扫描射灵智能弓箭的设备二维码。</text>
|
||||
</view>
|
||||
<view class="bind-failure-retry" @click="$clickSound(retryScan)">
|
||||
<text>重新扫码</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-bind-failure-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bind-failure-header {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 与绑定成功页共用固定画布,页面内的视觉尺寸全部按 375 宽设计稿换算为 rpx。 */
|
||||
.bind-failure-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bind-failure-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bind-failure-hero-wrap,
|
||||
.bind-failure-message,
|
||||
.bind-failure-retry {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.bind-failure-hero-wrap {
|
||||
top: 568rpx;
|
||||
left: 244rpx;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.bind-failure-hero-shadow {
|
||||
position: absolute;
|
||||
left: 22rpx;
|
||||
bottom: 0;
|
||||
width: 238rpx;
|
||||
height: 40rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.bind-failure-hero {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.bind-failure-message {
|
||||
top: 824rpx;
|
||||
left: 122rpx;
|
||||
display: flex;
|
||||
width: 504rpx;
|
||||
min-height: 80rpx;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: #ffffff;
|
||||
font-family: PingFang SC-Regular;
|
||||
font-size: 28rpx;
|
||||
font-weight: normal;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bind-failure-retry {
|
||||
top: 960rpx;
|
||||
left: 195rpx;
|
||||
display: flex;
|
||||
width: 360rpx;
|
||||
height: 70rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid #ffd947;
|
||||
border-radius: 78rpx;
|
||||
color: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 26rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,248 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import { getDeviceDetailAPI, getHomeData } from "@/apis";
|
||||
|
||||
const bindResult = ref({
|
||||
isFirstBind: false,
|
||||
expireDate: "",
|
||||
});
|
||||
|
||||
const bindSuccessHero = computed(() =>
|
||||
bindResult.value.isFirstBind
|
||||
? "../../static/device-assets/device-bind-success-hero-first.png"
|
||||
: "../../static/device-assets/device-bind-success-hero-nonfirst.png"
|
||||
);
|
||||
const bindRewardExpireDate = computed(() => bindResult.value.expireDate);
|
||||
|
||||
// 与会员页使用相同的到期时间格式,设备详情接口本身不提供会员有效期。
|
||||
const formatVipDate = (value) => {
|
||||
if (!value) return "";
|
||||
const numericValue = Number(value);
|
||||
const timestamp = Number.isNaN(numericValue)
|
||||
? new Date(value).getTime()
|
||||
: numericValue < 1000000000000
|
||||
? numericValue * 1000
|
||||
: numericValue;
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const loadBindResult = async (deviceId) => {
|
||||
if (!deviceId) return;
|
||||
try {
|
||||
const data = await getDeviceDetailAPI(deviceId);
|
||||
if (data?.detail?.isFirstBind !== true) return;
|
||||
bindResult.value.isFirstBind = true;
|
||||
|
||||
try {
|
||||
const homeData = await getHomeData();
|
||||
bindResult.value.expireDate = formatVipDate(
|
||||
homeData?.user?.normalVipExpiredAt
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("获取赠送会员有效期失败", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("获取设备详情失败", error);
|
||||
}
|
||||
};
|
||||
|
||||
const toFirstTryPage = () => {
|
||||
uni.navigateTo({ url: "/pages/first-try" });
|
||||
};
|
||||
|
||||
// 成功页由设备页 navigateTo 进入,优先返回上一页;页面栈异常时兜底回到设备页。
|
||||
const goBackToDevicePage = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack({ delta: 1 });
|
||||
return;
|
||||
}
|
||||
uni.redirectTo({ url: "/pages/device/my-device" });
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
bindResult.value = {
|
||||
isFirstBind: false,
|
||||
expireDate: "",
|
||||
};
|
||||
void loadBindResult(options.deviceId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view class="device-bind-success-page">
|
||||
<Container
|
||||
:bgType="12"
|
||||
bgColor="transparent"
|
||||
:onBack="goBackToDevicePage"
|
||||
headerClass="bind-success-header"
|
||||
:scroll="false"
|
||||
:usePageScroll="true"
|
||||
>
|
||||
<view class="bind-success-page">
|
||||
<view
|
||||
class="bind-success-scene"
|
||||
:class="{ 'bind-success-scene--first': bindResult.isFirstBind }"
|
||||
>
|
||||
<image
|
||||
class="bind-success-confetti"
|
||||
src="../../static/device-assets/device-bind-success-confetti.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<image class="bind-success-hero" :src="bindSuccessHero" mode="aspectFit" />
|
||||
<image
|
||||
class="bind-success-title-image"
|
||||
src="../../static/device-assets/device-bind-success-title.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view v-if="bindResult.isFirstBind" class="bind-reward-card">
|
||||
<image
|
||||
class="bind-reward-gift"
|
||||
src="../../static/device-assets/device-bind-success-reward-gift.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="bind-reward-text">
|
||||
新设备首次绑定礼包:
|
||||
<text class="bind-reward-highlight">6个月射灵会员</text>
|
||||
已自动发放至本账号<text v-if="bindRewardExpireDate">,有效期{{ bindRewardExpireDate }}</text>。
|
||||
</text>
|
||||
</view>
|
||||
<view class="bind-success-tutorial" @click="$clickSound(toFirstTryPage)">
|
||||
<text>立即查看新手教程</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-bind-success-page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.bind-success-header {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 绑定结果页使用固定画布,背景和前景素材按蓝湖 375 x 812 画布定位。 */
|
||||
.bind-success-page {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bind-success-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bind-success-confetti,
|
||||
.bind-success-hero,
|
||||
.bind-success-title-image,
|
||||
.bind-reward-card,
|
||||
.bind-success-tutorial {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.bind-success-confetti {
|
||||
top: 488rpx;
|
||||
left: 78rpx;
|
||||
width: 588rpx;
|
||||
height: 508rpx;
|
||||
}
|
||||
|
||||
.bind-success-hero {
|
||||
top: 568rpx;
|
||||
left: 244rpx;
|
||||
width: 260rpx;
|
||||
height: 222rpx;
|
||||
}
|
||||
|
||||
.bind-success-scene--first .bind-success-confetti {
|
||||
top: 314rpx;
|
||||
}
|
||||
|
||||
.bind-success-scene--first .bind-success-hero {
|
||||
top: 400rpx;
|
||||
}
|
||||
|
||||
.bind-success-title-image {
|
||||
top: 828rpx;
|
||||
left: 216rpx;
|
||||
width: 316rpx;
|
||||
height: 70rpx;
|
||||
}
|
||||
|
||||
.bind-success-scene--first .bind-success-title-image {
|
||||
top: 660rpx;
|
||||
}
|
||||
|
||||
.bind-reward-card {
|
||||
top: 768rpx;
|
||||
left: 170rpx;
|
||||
width: 508rpx;
|
||||
height: 152rpx;
|
||||
box-sizing: border-box;
|
||||
padding: 16rpx 16rpx 16rpx 90rpx;
|
||||
border-radius: 16rpx 64rpx 16rpx 64rpx;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.bind-reward-gift {
|
||||
position: absolute;
|
||||
top: -18rpx;
|
||||
left: -100rpx;
|
||||
width: 178rpx;
|
||||
height: 176rpx;
|
||||
}
|
||||
|
||||
.bind-reward-text {
|
||||
display: block;
|
||||
width: 380rpx;
|
||||
height: 120rpx;
|
||||
color: #ffffff;
|
||||
font-family: PingFang SC-Regular;
|
||||
font-size: 28rpx;
|
||||
font-weight: normal;
|
||||
line-height: 40rpx;
|
||||
}
|
||||
|
||||
.bind-reward-highlight {
|
||||
color: #ffd947;
|
||||
}
|
||||
|
||||
.bind-success-tutorial {
|
||||
top: 960rpx;
|
||||
left: 195rpx;
|
||||
display: flex;
|
||||
width: 360rpx;
|
||||
height: 70rpx;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2rpx solid #ffd947;
|
||||
border-radius: 78rpx;
|
||||
color: #ffd947;
|
||||
font-size: 26rpx;
|
||||
line-height: 26rpx;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -1,192 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import { getDeviceDetailAPI } from "@/apis";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const store = useStore();
|
||||
const { device } = storeToRefs(store);
|
||||
const deviceId = ref("");
|
||||
const qrImageUrl = ref("");
|
||||
const qrSaved = ref(false);
|
||||
const loading = ref(true);
|
||||
|
||||
const loadQrCode = async () => {
|
||||
if (!deviceId.value) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getDeviceDetailAPI(deviceId.value);
|
||||
const detail = response?.detail || response?.data?.detail;
|
||||
qrImageUrl.value = String(detail?.qrCodeUrl ?? "").trim();
|
||||
} catch (error) {
|
||||
console.error("获取设备二维码失败", error);
|
||||
qrImageUrl.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveQrCode = async () => {
|
||||
if (!qrImageUrl.value) return;
|
||||
|
||||
let filePath = qrImageUrl.value;
|
||||
try {
|
||||
if (/^https?:\/\//.test(filePath)) {
|
||||
filePath = await new Promise((resolve, reject) => {
|
||||
uni.downloadFile({
|
||||
url: filePath,
|
||||
success: (result) =>
|
||||
result.statusCode === 200
|
||||
? resolve(result.tempFilePath)
|
||||
: reject(new Error("二维码下载失败")),
|
||||
fail: reject,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
uni.saveImageToPhotosAlbum({ success: resolve, fail: reject, filePath });
|
||||
});
|
||||
qrSaved.value = true;
|
||||
uni.showToast({ title: "已保存至相册", icon: "success" });
|
||||
} catch (error) {
|
||||
uni.showToast({ title: "请长按二维码保存", icon: "none" });
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((options = {}) => {
|
||||
try {
|
||||
deviceId.value = decodeURIComponent(options.deviceId || "") || device.value.deviceId || "";
|
||||
} catch (error) {
|
||||
deviceId.value = device.value.deviceId || "";
|
||||
}
|
||||
void loadQrCode();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Container :bgType="12" :scroll="false">
|
||||
<view class="qr-page">
|
||||
<view class="qr-corner qr-corner--top"></view>
|
||||
<view class="qr-corner qr-corner--bottom"></view>
|
||||
<view class="qr-canvas">
|
||||
<text v-if="loading" class="qr-empty">二维码加载中...</text>
|
||||
<template v-else-if="qrImageUrl">
|
||||
<image class="qr-image" :src="qrImageUrl" mode="aspectFit" show-menu-by-longpress />
|
||||
<text v-if="qrSaved" class="qr-device-id">设备ID:{{ deviceId }}</text>
|
||||
<view v-else class="qr-save-button" @click="saveQrCode">
|
||||
<text>保存至相册</text>
|
||||
</view>
|
||||
<text class="qr-description">
|
||||
该二维码为当前绑定弓箭的二维码,你可以截图保存到相册,以便当二维码丢失或不在身边时,可以扫描二维码进行设备绑定。
|
||||
</text>
|
||||
<text class="qr-note">注:解除绑定后将无法查看该二维码。</text>
|
||||
</template>
|
||||
<text v-else class="qr-empty">暂无设备二维码</text>
|
||||
</view>
|
||||
</view>
|
||||
</Container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qr-page {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.qr-corner {
|
||||
position: absolute;
|
||||
width: 300rpx;
|
||||
height: 300rpx;
|
||||
background: #ffeb00;
|
||||
}
|
||||
|
||||
.qr-corner--top {
|
||||
top: -220rpx;
|
||||
right: -170rpx;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
|
||||
.qr-corner--bottom {
|
||||
bottom: -220rpx;
|
||||
left: -170rpx;
|
||||
transform: rotate(42deg);
|
||||
}
|
||||
|
||||
.qr-canvas {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 260rpx 62rpx 70rpx;
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 432rpx;
|
||||
height: 432rpx;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.qr-save-button {
|
||||
display: flex;
|
||||
width: 360rpx;
|
||||
height: 72rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 34rpx;
|
||||
border: 1rpx solid #e8c840;
|
||||
border-radius: 36rpx;
|
||||
color: #ffe846;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.qr-device-id,
|
||||
.qr-empty {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 26rpx;
|
||||
line-height: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-device-id {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.qr-empty {
|
||||
margin-top: 140rpx;
|
||||
}
|
||||
|
||||
.qr-description,
|
||||
.qr-note {
|
||||
width: 100%;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 22rpx;
|
||||
line-height: 36rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-description {
|
||||
margin-top: 42rpx;
|
||||
}
|
||||
|
||||
.qr-note {
|
||||
margin-top: 12rpx;
|
||||
color: rgba(255, 232, 70, 0.7);
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,156 +0,0 @@
|
||||
<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>
|
||||
+74
-271
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import Guide from "@/components/Guide.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import Swiper from "@/components/Swiper.vue";
|
||||
@@ -13,34 +13,20 @@ import BowPower from "@/components/BowPower.vue";
|
||||
import TestDistance from "@/components/TestDistance.vue";
|
||||
import BubbleTip from "@/components/BubbleTip.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
import {
|
||||
createPractiseAPI,
|
||||
getPractiseAPI,
|
||||
startPractiseAPI,
|
||||
laserAimAPI,
|
||||
laserCloseAPI,
|
||||
} from "@/apis";
|
||||
import { connectMatchWebSocket, closeMatchWebSocket } from "@/matchWebsocket";
|
||||
import { createPractiseAPI, getPractiseAPI } from "@/apis";
|
||||
import { sharePractiseData } from "@/canvas";
|
||||
import {
|
||||
wxShare,
|
||||
debounce,
|
||||
getDistanceCheckAudioKey,
|
||||
getShootValidation,
|
||||
} from "@/util";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import { wxShare, debounce } from "@/util";
|
||||
import { MESSAGETYPES } from "@/constants";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, device } = storeToRefs(store);
|
||||
const { user } = storeToRefs(store);
|
||||
const scores = ref([]);
|
||||
const isSvip = ref(false);
|
||||
const step = ref(0);
|
||||
const total = 12;
|
||||
const stepButtonTexts = [
|
||||
"开始",
|
||||
"进入下一个任务",
|
||||
"我已校准",
|
||||
"进入下一个任务",
|
||||
"我准备好了,开始",
|
||||
"",
|
||||
@@ -52,152 +38,56 @@ const practiseResult = ref({});
|
||||
const btnDisabled = ref(false);
|
||||
const practiseId = ref("");
|
||||
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 = [
|
||||
"https://static.shelingxingqiu.com/shootmini/static/target.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwdpgwt9e6du.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wvv9sw4zioqk.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0ww3khaycallu.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wtkcvaxxv0s8.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wry5tw7ltmxr.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wu3kcdrwzwpd.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2026-02-08/dg9ev0wwr6hfjhyfn5.png",
|
||||
];
|
||||
|
||||
const calibrationGuides = [
|
||||
{
|
||||
title: "箭头面向靶子",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5wscg7hrfo.png",
|
||||
},
|
||||
{
|
||||
title: "摆出拉弓姿势",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-30/ddv9p5fk5b7ljrhx3o.png",
|
||||
},
|
||||
{
|
||||
title: "调整瞄准器",
|
||||
src: "https://static.shelingxingqiu.com/attachment/2025-10-29/dduexjgrcxf9wjaiv4.png",
|
||||
},
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68bs7z5elwvw7.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68qmi7grgreen.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68hgrw1ip4wae.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x684x8zmfrmbla.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x67sding7fodnk.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68mpug7cac4yt.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68my783mlmgxv.png",
|
||||
"https://static.shelingxingqiu.com/attachment/2025-07-09/db77x68p48ylzirtb0.png",
|
||||
];
|
||||
|
||||
const onSwiperIndexChange = (index) => {
|
||||
guideSwiperIndex.value = index;
|
||||
showGuide.value = index + 1 === guideImages.length;
|
||||
};
|
||||
|
||||
const isGuideLastImage = computed(
|
||||
() => guideSwiperIndex.value + 1 === guideImages.length
|
||||
);
|
||||
|
||||
const currentStepButtonText = computed(() => {
|
||||
if (step.value === 1 && isGuideLastImage.value) return "去校准智能弓";
|
||||
return stepButtonTexts[step.value];
|
||||
});
|
||||
|
||||
const openCalibrationLaser = async () => {
|
||||
if (laserActive.value) return;
|
||||
await laserAimAPI();
|
||||
laserActive.value = true;
|
||||
};
|
||||
|
||||
const closeCalibrationLaser = async () => {
|
||||
if (!laserActive.value) return;
|
||||
await laserCloseAPI();
|
||||
laserActive.value = false;
|
||||
if (index + 1 === guideImages.length) {
|
||||
showGuide.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const createPractise = async (arrows) => {
|
||||
closeMatchWebSocket({ reason: "practice-recreate" });
|
||||
const result = await createPractiseAPI(
|
||||
arrows,
|
||||
120,
|
||||
2,
|
||||
device.value.deviceId
|
||||
);
|
||||
const result = await createPractiseAPI(arrows, 1);
|
||||
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 (message) => {
|
||||
practiseResult.value = Array.isArray(message?.details)
|
||||
? message
|
||||
: await getPractiseAPI(practiseId.value);
|
||||
const onOver = async () => {
|
||||
start.value = false;
|
||||
practiseResult.value = await getPractiseAPI(practiseId.value);
|
||||
};
|
||||
|
||||
const beginPractise = async () => {
|
||||
if (!practiseId.value) {
|
||||
uni.showToast({
|
||||
title: "练习未创建,请重试",
|
||||
icon: "none",
|
||||
async function onReceiveMessage(messages = []) {
|
||||
messages.forEach((msg) => {
|
||||
if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
if (step.value === 2 && msg.target.dst / 100 >= 5) {
|
||||
btnDisabled.value = false;
|
||||
showGuide.value = true;
|
||||
} else if (scores.value.length < total) {
|
||||
scores.value.push(msg.target);
|
||||
}
|
||||
if (scores.value.length === total) {
|
||||
setTimeout(onOver, 1500);
|
||||
}
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await startPractiseAPI(practiseId.value);
|
||||
return true;
|
||||
};
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
isSvip.value = msg.sVip === true;
|
||||
scores.value = Array.isArray(msg.details) ? msg.details : scores.value;
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
setTimeout(() => onOver(msg), 1500);
|
||||
} else if (msg.type === MESSAGETYPESV2.TestDistance && step.value === 3) {
|
||||
const result = getShootValidation(msg.shootData);
|
||||
const isQualified = result.distanceOk && result.targetOk;
|
||||
audioManager.play(getDistanceCheckAudioKey(msg.shootData));
|
||||
btnDisabled.value = !isQualified;
|
||||
showGuide.value = isQualified;
|
||||
}
|
||||
// messages.forEach((msg) => {
|
||||
// if (msg.constructor === MESSAGETYPES.ShootSyncMeArrowID) {
|
||||
// if (step.value === 2 && msg.target.dst / 100 >= 5) {
|
||||
// btnDisabled.value = false;
|
||||
// showGuide.value = true;
|
||||
// } else if (scores.value.length < total) {
|
||||
// scores.value.push(msg.target);
|
||||
// }
|
||||
// if (scores.value.length === total) {
|
||||
// setTimeout(onOver, 1500);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
}
|
||||
|
||||
const onClickShare = debounce(async () => {
|
||||
if (sharing.value) return;
|
||||
sharing.value = true;
|
||||
try {
|
||||
await sharePractiseData("shareCanvas", 1, user.value, practiseResult.value);
|
||||
await wxShare("shareCanvas");
|
||||
} catch (e) {
|
||||
uni.showToast({
|
||||
title: "海报生成失败,请稍后重试",
|
||||
icon: "none",
|
||||
});
|
||||
} finally {
|
||||
sharing.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void audioManager.warmCommon();
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: true,
|
||||
});
|
||||
@@ -205,102 +95,72 @@ onMounted(() => {
|
||||
uni.$on("share-image", onClickShare);
|
||||
});
|
||||
|
||||
onBeforeUnmount(async () => {
|
||||
onBeforeUnmount(() => {
|
||||
uni.setKeepScreenOn({
|
||||
keepScreenOn: false,
|
||||
});
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("share-image", onClickShare);
|
||||
await closeCalibrationLaser();
|
||||
audioManager.stopAll();
|
||||
closeMatchWebSocket({ reason: "practice-leave" });
|
||||
});
|
||||
|
||||
const nextStep = async () => {
|
||||
if (step.value === 0) {
|
||||
step.value = 1;
|
||||
title.value = "-箭前准备";
|
||||
title.value = "-凹造型";
|
||||
} else if (step.value === 1) {
|
||||
if (!isGuideLastImage.value) {
|
||||
guideSwiperIndex.value += 1;
|
||||
showGuide.value = guideSwiperIndex.value + 1 === guideImages.length;
|
||||
return;
|
||||
}
|
||||
showGuide.value = false;
|
||||
step.value = 2;
|
||||
// title.value = "-校准智能弓";
|
||||
await openCalibrationLaser();
|
||||
} else if (step.value === 2) {
|
||||
await closeCalibrationLaser();
|
||||
showGuide.value = false;
|
||||
btnDisabled.value = true;
|
||||
step.value = 3;
|
||||
title.value = "-感知距离";
|
||||
const result = await createPractise(total);
|
||||
if (!result) {
|
||||
btnDisabled.value = false;
|
||||
step.value = 2;
|
||||
return;
|
||||
}
|
||||
} else if (step.value === 3) {
|
||||
title.value = "-感知距离";
|
||||
} else if (step.value === 2) {
|
||||
showGuide.value = false;
|
||||
step.value = 4;
|
||||
step.value = 3;
|
||||
title.value = "-小试牛刀";
|
||||
} else if (step.value === 4) {
|
||||
} else if (step.value === 3) {
|
||||
title.value = "小试牛刀";
|
||||
const result = await beginPractise();
|
||||
if (!result) return;
|
||||
await createPractise(total);
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
step.value = 5;
|
||||
step.value = 4;
|
||||
start.value = true;
|
||||
setTimeout(() => {
|
||||
uni.$emit("play-sound", "请开始射击");
|
||||
}, 300);
|
||||
} else if (step.value === 6) {
|
||||
} else if (step.value === 5) {
|
||||
uni.navigateBack({
|
||||
delta: 1,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = async () => {
|
||||
const validArrows = (practiseResult.value.details || []).filter(
|
||||
const onClose = () => {
|
||||
const validArrows = (practiseResult.value.arrows || []).filter(
|
||||
(a) => a.x !== -30 && a.y !== -30
|
||||
);
|
||||
if (validArrows.length === total) {
|
||||
setTimeout(() => {
|
||||
practiseResult.value = {};
|
||||
showGuide.value = false;
|
||||
step.value = 6;
|
||||
step.value = 5;
|
||||
}, 500);
|
||||
} else {
|
||||
practiseResult.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
isSvip.value = false;
|
||||
step.value = 4;
|
||||
await createPractise(total);
|
||||
step.value = 3;
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
<Container :bgType="1" :title="title" :showBottom="step !== 5">
|
||||
<Container :bgType="1" :title="title" :showBottom="step !== 4">
|
||||
<view class="container">
|
||||
<Guide
|
||||
v-if="step !== 5"
|
||||
v-if="step !== 4"
|
||||
:type="
|
||||
step === 3
|
||||
step === 2
|
||||
? 2
|
||||
: step === 6 || (step === 0 && user.nickName.length > 6)
|
||||
: step === 5 || (step === 0 && user.nickName.length > 6)
|
||||
? 1
|
||||
: 0
|
||||
"
|
||||
@@ -316,28 +176,25 @@ const getResultTipSrc = (result = {}) => {
|
||||
,这是新人必刷小任务,0基础小白也能快速掌握弓箭技巧和游戏规则哦~:)
|
||||
</text>
|
||||
<text v-if="step === 1" :style="{ fontSize: '28rpx' }"
|
||||
>这位就是人帅技高的高教练!接下来请跟随教练指引,做好射箭前期准备。</text
|
||||
>
|
||||
<text v-if="step === 2" :style="{ fontSize: '28rpx' }"
|
||||
>请按下方步骤完成智能弓校准,让瞄准器和靶子保持对齐。</text
|
||||
>这是我们人帅技高的高教练。首先,请按教练示范,尝试自己去做这些动作和手势吧。</text
|
||||
>
|
||||
<view
|
||||
class="guide-tips"
|
||||
:style="{ marginTop: '8rpx' }"
|
||||
v-if="step === 3"
|
||||
v-if="step === 2"
|
||||
>
|
||||
<text>你知道5米射程有多远吗?</text>
|
||||
<text>
|
||||
在我们的排位赛中,射程小于5米的成绩无效、哦!建议平时练习距离至少5米。现在,来边射箭边调整你的站位点吧!
|
||||
</text>
|
||||
</view>
|
||||
<view class="guide-tips" v-if="step === 4">
|
||||
<view class="guide-tips" v-if="step === 3">
|
||||
<text>一切准备就绪</text>
|
||||
<text :style="{ fontSize: '28rpx' }"
|
||||
>试着完成一个真正的弓箭手任务吧!</text
|
||||
>
|
||||
</view>
|
||||
<view class="guide-tips" v-if="step === 6">
|
||||
<view class="guide-tips" v-if="step === 5">
|
||||
<text>新手试炼场通关啦,优秀!</text>
|
||||
<text :style="{ fontSize: '28rpx' }"
|
||||
>反曲弓运动基本知识和射灵世界系统规则你已Get,是不是挺容易呀:)</text
|
||||
@@ -354,67 +211,52 @@ const getResultTipSrc = (result = {}) => {
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-11-17/deas80ef1sf9td0leq.png"
|
||||
class="try-tip"
|
||||
mode="widthFix"
|
||||
v-if="step === 4"
|
||||
v-if="step === 3"
|
||||
/>
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-07-01/db0ehpz9lav58g5drl.png"
|
||||
class="try-tip"
|
||||
mode="widthFix"
|
||||
v-if="step === 6"
|
||||
v-if="step === 5"
|
||||
/>
|
||||
<view style="height: 570px" v-if="step === 1">
|
||||
<Swiper
|
||||
:current="guideSwiperIndex"
|
||||
:onChange="onSwiperIndexChange"
|
||||
:data="guideImages"
|
||||
/>
|
||||
<Swiper :onChange="onSwiperIndexChange" :data="guideImages" />
|
||||
</view>
|
||||
<view class="calibration-container" v-if="step === 2">
|
||||
<view
|
||||
v-for="(guide, index) in calibrationGuides"
|
||||
:key="guide.title"
|
||||
class="calibration-guide"
|
||||
>
|
||||
<view>
|
||||
<text>{{ index + 1 }}</text>
|
||||
<text>{{ guide.title }}</text>
|
||||
</view>
|
||||
<image :src="guide.src" mode="widthFix" />
|
||||
</view>
|
||||
<text>请完成以上步骤校准智能弓</text>
|
||||
</view>
|
||||
<ShootProgress v-if="step === 5" tips="请开始连续射箭" :start="start" />
|
||||
<TestDistance v-if="step === 3" :guide="false" :targetType="40" />
|
||||
<ShootProgress v-if="step === 4" tips="请开始连续射箭" :start="start" />
|
||||
<TestDistance v-if="step === 2" :guide="false" />
|
||||
<view
|
||||
class="user-row"
|
||||
v-if="step === 5"
|
||||
:style="{ marginBottom: '0' }"
|
||||
v-if="step === 4"
|
||||
:style="{ marginBottom: step === 2 ? '40px' : '0' }"
|
||||
>
|
||||
<Avatar :src="user.avatar" :size="35" />
|
||||
<BowPower />
|
||||
</view>
|
||||
<BowTarget
|
||||
v-if="step === 5"
|
||||
:currentRound="step === 5 ? scores.length : 0"
|
||||
:totalRound="step === 5 ? total : 0"
|
||||
v-if="step === 4"
|
||||
:currentRound="step === 4 ? scores.length : 0"
|
||||
:totalRound="step === 4 ? total : 0"
|
||||
:scores="scores"
|
||||
:isSvip="isSvip"
|
||||
:targetType="40"
|
||||
stable-shot-effect
|
||||
/>
|
||||
<ScorePanel
|
||||
v-if="step === 5"
|
||||
v-if="step === 4"
|
||||
:total="total"
|
||||
:rowCount="6"
|
||||
:arrows="scores"
|
||||
:scores="scores.map((s) => s.ring)"
|
||||
/>
|
||||
<ScoreResult
|
||||
v-if="practiseResult.details"
|
||||
v-if="practiseResult.arrows"
|
||||
:rowCount="6"
|
||||
:total="total"
|
||||
:onClose="onClose"
|
||||
:result="practiseResult"
|
||||
:tipSrc="getResultTipSrc(practiseResult)"
|
||||
:tipSrc="`../static/${
|
||||
practiseResult.arrows.filter(
|
||||
(arrow) => arrow.x !== -30 && arrow.y !== -30
|
||||
).length < total
|
||||
? 'un'
|
||||
: ''
|
||||
}finish-tip.png`"
|
||||
/>
|
||||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||||
</view>
|
||||
@@ -425,7 +267,7 @@ const getResultTipSrc = (result = {}) => {
|
||||
step === 1 ? "学会了,我摆得比教练还帅" : "我找到合适的点位了"
|
||||
}}</text>
|
||||
</BubbleTip>
|
||||
{{ currentStepButtonText }}
|
||||
{{ stepButtonTexts[step] }}
|
||||
</SButton>
|
||||
</template>
|
||||
</Container>
|
||||
@@ -439,43 +281,4 @@ const getResultTipSrc = (result = {}) => {
|
||||
width: calc(100% - 20px);
|
||||
margin: 0 10px;
|
||||
}
|
||||
.calibration-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.calibration-guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
margin-bottom: 15rpx;
|
||||
}
|
||||
.calibration-guide > view {
|
||||
width: 100%;
|
||||
margin: 25rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.calibration-guide > view > text:first-child {
|
||||
font-size: 24rpx;
|
||||
background: #e89024;
|
||||
border-radius: 50%;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
line-height: 32rpx;
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
.calibration-guide > image {
|
||||
width: 630rpx;
|
||||
height: 250rpx;
|
||||
}
|
||||
.calibration-container > text {
|
||||
font-size: 24rpx;
|
||||
color: #fff9;
|
||||
margin: 30rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+50
-190
@@ -1,23 +1,21 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import Container from "@/components/Container.vue";
|
||||
import GuideTwo from "@/components/GuideTwo.vue";
|
||||
import Guide from "@/components/Guide.vue";
|
||||
import SButton from "@/components/SButton.vue";
|
||||
import SModal from "@/components/SModal.vue";
|
||||
import Signin from "@/components/Signin.vue";
|
||||
import CreateRoom from "@/components/CreateRoom.vue";
|
||||
import Avatar from "@/components/Avatar.vue";
|
||||
import ModalDialog from "@/components/ModalDialog.vue";
|
||||
|
||||
import { getRoomAPI, joinRoomAPI, getBattleDataAPI, getDailyCountAPI } from "@/apis";
|
||||
import { debounce, canEenter, getLimitCountText, isLimitReached } from "@/util";
|
||||
import { getRoomAPI, joinRoomAPI, getBattleDataAPI } from "@/apis";
|
||||
import { debounce, canEenter } from "@/util";
|
||||
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
const { user, device, online, game, dailyCount } = storeToRefs(store);
|
||||
const { updateDailyCount } = store;
|
||||
const { user, device, online, game } = storeToRefs(store);
|
||||
|
||||
const showModal = ref(false);
|
||||
const showSignin = ref(false);
|
||||
@@ -25,17 +23,8 @@ const warnning = ref("");
|
||||
const roomNumber = ref("");
|
||||
const data = ref({});
|
||||
const roomID = ref("");
|
||||
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) => {
|
||||
if (loading.value) return;
|
||||
if (!canEenter(user.value, device.value, online.value)) return;
|
||||
if (game.value.inBattle) {
|
||||
uni.$showHint(1);
|
||||
@@ -44,17 +33,9 @@ const enterRoom = debounce(async (number) => {
|
||||
if (!number) {
|
||||
warnning.value = "请输入房间号";
|
||||
showModal.value = true;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
let keepLoading = false;
|
||||
try {
|
||||
} else {
|
||||
const room = await getRoomAPI(number);
|
||||
if (!room.number) {
|
||||
warnning.value = room.started ? "该房间对战已开始,无法加入" : "查无此房";
|
||||
showModal.value = true;
|
||||
return;
|
||||
}
|
||||
if (room.number) {
|
||||
const alreadyIn = room.members.find(
|
||||
(item) => item.userInfo.id === user.value.id
|
||||
);
|
||||
@@ -66,74 +47,27 @@ const enterRoom = debounce(async (number) => {
|
||||
return;
|
||||
}
|
||||
}
|
||||
keepLoading = true;
|
||||
uni.navigateTo({
|
||||
url: "/pages/battle-room?roomNumber=" + number,
|
||||
fail: () => {
|
||||
loading.value = false;
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
if (!keepLoading) loading.value = false;
|
||||
} else {
|
||||
warnning.value = room.started ? "该房间对战已开始,无法加入" : "查无此房";
|
||||
showModal.value = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
const onCreateRoom = debounce(async () => {
|
||||
if (createRoomLoading.value) return;
|
||||
const onCreateRoom = async () => {
|
||||
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 = "";
|
||||
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 = () => {
|
||||
if (roomID.value && user.value.id) enterRoom(roomID.value);
|
||||
showSignin.value = false;
|
||||
};
|
||||
|
||||
/** 跳转到我的战绩页面,默认展示「好友约战」tab */
|
||||
const goMyRecord = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/my-growth?tab=1',
|
||||
});
|
||||
};
|
||||
onShow(async () => {
|
||||
loading.value = false;
|
||||
if (user.value.id) {
|
||||
const [result] = await Promise.all([
|
||||
getBattleDataAPI(),
|
||||
loadDailyCount(),
|
||||
]);
|
||||
const result = await getBattleDataAPI();
|
||||
data.value = result;
|
||||
}
|
||||
});
|
||||
@@ -149,28 +83,16 @@ onLoad(async (options) => {
|
||||
<template>
|
||||
<Container title="好友约战" :showBackToGame="true">
|
||||
<view :style="{ width: '100%', height: '100%' }">
|
||||
<GuideTwo>
|
||||
<Guide>
|
||||
<view class="guide-tips">
|
||||
<text class="guide-tips__main">约上朋友开几局,欢乐多,不寂寞</text>
|
||||
<text class="guide-tips__sub">一起练升级更快,早日加入全国排位赛!</text>
|
||||
<text>约上朋友开几局,欢乐多,不寂寞</text>
|
||||
<text>一起练升级更快,早日加入全国排位赛!</text>
|
||||
</view>
|
||||
</GuideTwo>
|
||||
</Guide>
|
||||
<view class="my-data">
|
||||
<view>
|
||||
<Avatar :rankLvl="user.rankLvl" :src="user.avatar" :size="30" />
|
||||
<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="truncate">{{ user.nickName }}</text>
|
||||
</view>
|
||||
<view>
|
||||
<view>
|
||||
@@ -190,9 +112,16 @@ onLoad(async (options) => {
|
||||
<view>
|
||||
<view class="stars">
|
||||
<block v-for="i in 5" :key="i">
|
||||
<image v-if="data.totalWinningRate >= i * 0.2" src="../static/star-full.png" mode="widthFix" />
|
||||
<image v-else-if="data.totalWinningRate >= (i - 1) * 0.2 + 0.1" src="../static/star-half.png"
|
||||
mode="widthFix" />
|
||||
<image
|
||||
v-if="data.totalWinningRate >= i * 0.2"
|
||||
src="../static/star-full.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
v-else-if="data.totalWinningRate >= (i - 1) * 0.2 + 0.1"
|
||||
src="../static/star-half.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image v-else src="../static/star-empty.png" mode="widthFix" />
|
||||
</block>
|
||||
</view>
|
||||
@@ -203,73 +132,49 @@ onLoad(async (options) => {
|
||||
<view class="founded-room">
|
||||
<image src="../static/founded-room.png" mode="widthFix" />
|
||||
<view>
|
||||
<input placeholder="输入房间号" v-model="roomNumber" placeholder-style="color: #ccc" />
|
||||
<view @click="$clickSound(() => enterRoom(roomNumber))">进入房间</view>
|
||||
<input
|
||||
placeholder="输入房间号"
|
||||
v-model="roomNumber"
|
||||
placeholder-style="color: #ccc"
|
||||
/>
|
||||
<view @click="enterRoom(roomNumber)">进入房间</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="create-room">
|
||||
<image src="https://static.shelingxingqiu.com/attachment/2025-07-15/dbcejys872iyun92h6.png" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/room-notfound-title.png" mode="widthFix" />
|
||||
<image
|
||||
src="https://static.shelingxingqiu.com/attachment/2025-07-15/dbcejys872iyun92h6.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image src="../static/room-notfound-title.png" mode="widthFix" />
|
||||
<view>
|
||||
<image :src="user.avatar" mode="widthFix" />
|
||||
<image src="https://static.shelingxingqiu.com/shootmini/static/versus.png" mode="widthFix" />
|
||||
<image src="../static/versus.png" mode="widthFix" />
|
||||
<view>
|
||||
<image src="../static/question-mark.png" mode="widthFix" />
|
||||
</view>
|
||||
</view>
|
||||
<view>
|
||||
<view v-if="challengeLimitText" class="pp-text">
|
||||
{{ challengeLimitText }}
|
||||
</view>
|
||||
<SButton
|
||||
width="80%"
|
||||
:rounded="30"
|
||||
:disabled="createRoomLoading"
|
||||
:onClick="() => { $clickSound(); return onCreateRoom(); }"
|
||||
>
|
||||
<SButton width="80%" :rounded="30" :onClick="onCreateRoom">
|
||||
创建约战房
|
||||
</SButton>
|
||||
</view>
|
||||
</view>
|
||||
<SModal :show="showModal" :onClose="() => (showModal = false)" height="716rpx">
|
||||
<SModal
|
||||
:show="showModal"
|
||||
:onClose="() => (showModal = false)"
|
||||
height="520rpx"
|
||||
>
|
||||
<view v-if="warnning" class="warnning">
|
||||
{{ warnning }}
|
||||
</view>
|
||||
<!-- showModal 关闭时立即销毁组件,重开时重建,确保选项重置为 0 -->
|
||||
<CreateRoom v-if="!warnning && showModal" :onConfirm="() => (showModal = false)" />
|
||||
<CreateRoom v-if="!warnning" :onConfirm="() => (showModal = false)" />
|
||||
</SModal>
|
||||
<Signin :show="showSignin" :onClose="onSignin" />
|
||||
</view>
|
||||
</Container>
|
||||
<ModalDialog
|
||||
:show="showLimitModal"
|
||||
:content="'今日约战次数已经用完\n开通会员可增加次数'"
|
||||
cancelText="知道了"
|
||||
confirmText="去开通"
|
||||
:onCancel="closeLimitModal"
|
||||
:onConfirm="goVipPage"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.guide-tips {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: 112rpx;
|
||||
width: 100%;
|
||||
}
|
||||
.guide-tips__main {
|
||||
font-weight: 400;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 217, 71, 0.8);
|
||||
}
|
||||
.guide-tips__sub {
|
||||
font-weight: 400;
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.founded-room {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -280,11 +185,9 @@ onLoad(async (options) => {
|
||||
border-radius: 10px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.founded-room > image {
|
||||
width: 16vw;
|
||||
}
|
||||
|
||||
.founded-room > view {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -295,7 +198,6 @@ onLoad(async (options) => {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.founded-room > view > input {
|
||||
width: 70%;
|
||||
text-align: center;
|
||||
@@ -303,7 +205,6 @@ onLoad(async (options) => {
|
||||
height: 40px;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.founded-room > view > view {
|
||||
background-color: #fed847;
|
||||
width: 30%;
|
||||
@@ -315,44 +216,37 @@ onLoad(async (options) => {
|
||||
color: #000;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.create-room {
|
||||
position: relative;
|
||||
margin: 15px;
|
||||
height: 50vw;
|
||||
}
|
||||
|
||||
.create-room > image:first-of-type {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.create-room > image:nth-of-type(2) {
|
||||
padding: 15px;
|
||||
width: 25vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.create-room > view:nth-child(3) {
|
||||
margin: 12vw auto 5vw auto;
|
||||
margin: 12vw auto;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.create-room > view > image:first-child {
|
||||
width: 19vw;
|
||||
transform: translateY(-60%);
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.create-room > view > image:nth-child(2) {
|
||||
width: 37vw;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.create-room > view > view:nth-child(3) {
|
||||
position: relative;
|
||||
width: 19vw;
|
||||
@@ -364,19 +258,10 @@ onLoad(async (options) => {
|
||||
align-items: center;
|
||||
transform: translateY(60%);
|
||||
}
|
||||
|
||||
.create-room > view > view:nth-child(3) > image {
|
||||
width: 20px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.pp-text{
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 22rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.warnning {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -385,7 +270,6 @@ onLoad(async (options) => {
|
||||
align-items: center;
|
||||
color: #fff9;
|
||||
}
|
||||
|
||||
.my-data {
|
||||
width: calc(100% - 30px);
|
||||
margin: 15px;
|
||||
@@ -395,13 +279,11 @@ onLoad(async (options) => {
|
||||
overflow: hidden;
|
||||
background-color: #54431d33;
|
||||
}
|
||||
|
||||
.my-data > view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
color: #fff9;
|
||||
}
|
||||
|
||||
.my-data > view:first-child {
|
||||
width: calc(100% - 30px);
|
||||
align-items: flex-end;
|
||||
@@ -410,32 +292,15 @@ onLoad(async (options) => {
|
||||
margin: 15px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.my-data>view:first-child>.my-record-btn {
|
||||
font-weight: 400;
|
||||
font-size: 24rpx;
|
||||
color: #76D4FF;
|
||||
text-align: center;
|
||||
font-style: normal;
|
||||
width: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.my-data>view:first-child>.member-nickname {
|
||||
.my-data > view:first-child > text {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
margin-left: 10px;
|
||||
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 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.my-data > view:last-child > view {
|
||||
width: 33%;
|
||||
margin-top: 15px;
|
||||
@@ -444,29 +309,24 @@ onLoad(async (options) => {
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.my-data > view:last-child > view > view {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.my-data > view:last-child > view > view > text:first-child {
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
margin-right: 5px;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
.my-data > view:last-child > view:nth-child(2) {
|
||||
border-left: 1px solid #48494e;
|
||||
border-right: 1px solid #48494e;
|
||||
}
|
||||
|
||||
.my-data > view:last-child > view > view {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.stars > image {
|
||||
width: 4vw;
|
||||
height: 4vw;
|
||||
|
||||
+27
-114
@@ -1,66 +1,31 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import Container from "@/components/Container.vue";
|
||||
import useStore from "@/store";
|
||||
import { storeToRefs } from "pinia";
|
||||
const store = useStore();
|
||||
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>
|
||||
|
||||
<template>
|
||||
<Container title="等级介绍">
|
||||
<view class="container">
|
||||
<!-- 等级进度条 -->
|
||||
<scroll-view
|
||||
class="level-progress"
|
||||
scroll-x
|
||||
:show-scrollbar="false"
|
||||
:scroll-left="currentLevelScrollLeft"
|
||||
scroll-with-animation
|
||||
>
|
||||
<view class="level-track">
|
||||
<view class="level-progress">
|
||||
<view v-for="(_, index) in 10" :key="index" class="progress-dot">
|
||||
<view
|
||||
v-for="level in levels"
|
||||
:id="`level-${level}`"
|
||||
:key="level"
|
||||
:class="[
|
||||
'level-node',
|
||||
level < currentLevel ? 'level-node--done' : '',
|
||||
level === currentLevel ? 'level-node--current' : '',
|
||||
]"
|
||||
>
|
||||
<view class="level-node__top">
|
||||
<view class="level-node__dot" />
|
||||
<view v-if="level < MAX_LEVEL" class="level-node__line" />
|
||||
</view>
|
||||
<text class="level-node__label">{{ level }}</text>
|
||||
:style="{
|
||||
backgroundColor:
|
||||
index + 1 < user.lvl
|
||||
? '#fff9'
|
||||
: index + 1 === user.lvl
|
||||
? '#fed847'
|
||||
: 'transparent',
|
||||
borderColor: index + 1 === user.lvl ? '#fed847' : '#fff9',
|
||||
}"
|
||||
/>
|
||||
<view />
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 说明文本 -->
|
||||
<view class="body">
|
||||
@@ -100,80 +65,28 @@ const currentLevelScrollLeft = computed(() => {
|
||||
|
||||
.level-progress {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
padding: 24rpx 0 34rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.level-track {
|
||||
display: inline-flex;
|
||||
align-items: flex-start;
|
||||
padding-left: 20rpx;
|
||||
padding-right: 20rpx;
|
||||
}
|
||||
|
||||
.level-node {
|
||||
height: 32rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 72rpx;
|
||||
flex: 0 0 72rpx;
|
||||
justify-content: center;
|
||||
padding-top: 20rpx;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.level-node__top {
|
||||
.progress-dot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.level-node__dot {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
.progress-dot > view:first-child {
|
||||
width: 3.8vw;
|
||||
height: 3.8vw;
|
||||
border-radius: 50%;
|
||||
border: 3rpx solid rgba(255, 255, 255, 0.45);
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #fff9;
|
||||
}
|
||||
|
||||
.level-node__line {
|
||||
flex: 1;
|
||||
height: 2rpx;
|
||||
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;
|
||||
.progress-dot > view:last-child {
|
||||
width: 3.8vw;
|
||||
height: 1px;
|
||||
margin: 0 2px;
|
||||
background-color: #fff9;
|
||||
}
|
||||
|
||||
.body {
|
||||
|
||||
+101
-738
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