diff --git a/AGENTS.md b/AGENTS.md index b03bab1..4165d70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,6 +264,7 @@ AI 应主动: * 少解释 * 优先 patch * 优先 diff +* 写好中文注释 除非用户明确要求: 否则不要输出完整项目。 diff --git a/doc.md b/doc.md index ca46096..9566c3c 100644 --- a/doc.md +++ b/doc.md @@ -113,4 +113,134 @@ git push origin test 2. **每次合并前先拉取最新代码**,避免覆盖他人改动 3. **体验版发布前确认代码已提交**,避免遗漏 4. **开发分支命名建议**:`feature/姓名-功能名`,如 `feature/zhangsan-login` -5. **删除已合并的开发分支**:`git branch -d feature/your-name-work` \ No newline at end of file +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 编解码器 | `` 标记区间内禁止手动修改 | +| `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` 缺少生成区块标记 | 标记被误删或生成区块被手改 | 恢复 `` 标记,重新执行生成 | +| 协议生成成功但构建失败 | 问题位于项目构建或业务代码,不是字段表同步 | 保留生成结果,按构建错误继续定位 | + +### 7. 禁止事项与验收清单 + +禁止: + +- 禁止在运行时代码中导入 `match.min.js`。 +- 禁止手动修改 `` 与 `` 之间的内容。 +- 禁止绕过 `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. 微信开发者工具中的主包、分包体积符合上传限制。 diff --git a/package.json b/package.json index 4fe0d15..b4d948c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,12 @@ "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": { diff --git a/project.config.json b/project.config.json index 0ffafb0..b9375da 100644 --- a/project.config.json +++ b/project.config.json @@ -235,6 +235,14 @@ { "type": "file", "value": "static/tab-mall.png" + }, + { + "type": "folder", + "value": "static/training-home" + }, + { + "type": "folder", + "value": "static/training-difficulty-design" } ], "include": [] diff --git a/scripts/generate-match-schema.mjs b/scripts/generate-match-schema.mjs new file mode 100644 index 0000000..47d7a76 --- /dev/null +++ b/scripts/generate-match-schema.mjs @@ -0,0 +1,441 @@ +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const projectRoot = resolve(scriptDirectory, ".."); +const sourcePath = resolve(projectRoot, "src/utils/match.min.js"); +const runtimePath = resolve(projectRoot, "src/utils/matchProtocol.js"); +const generatedStartMarker = "// "; +const generatedEndMarker = "// "; + +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; +} diff --git a/src/App.vue b/src/App.vue index 608b1ec..1b364ec 100644 --- a/src/App.vue +++ b/src/App.vue @@ -21,7 +21,9 @@ import audioManager from "./audioManager"; const store = useStore(); const { - user + user, + device, + online } = storeToRefs(store); const { updateUser, @@ -68,7 +70,11 @@ async function emitUpdateOnline() { const data = await getDeviceBatteryAPI(); - updateOnline(data.online); + const wasOnline = Boolean(online.value); + const nextOnline = Boolean(data.online); + updateOnline(nextOnline); + if (!device.value.deviceId || wasOnline === nextOnline) return; + audioManager.play(nextOnline ? "设备已连接" : "设备连接已断开"); } function onDeviceBindInvalid() { diff --git a/src/apis.js b/src/apis.js index 2ec8229..b55a320 100644 --- a/src/apis.js +++ b/src/apis.js @@ -26,8 +26,6 @@ try { } const ADDONS_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api/shoot"); -const PRACTICE_BASE_URL = BASE_URL.replace(/\/api\/shoot$/, "/api"); - // 统一处理业务接口请求,包含登录态、业务错误和 WiFi 连接空响应兼容。 function request(method, url, data = {}, baseUrl = BASE_URL) { const token = uni.getStorageSync( @@ -188,8 +186,19 @@ export const getDailyCountAPI = () => { return request("GET", "/index/dailyCount", {}, ADDONS_BASE_URL); }; -export const getHomeData = (seasonId) => { - return request("GET", `/user/myHome?seasonId=${seasonId}`); +export const getHomeData = async (seasonId) => { + const data = await request("GET", `/user/myHome?seasonId=${seasonId}`); + if (!data?.user) return data; + + // 段位信息由 myHome 接口直接返回,统一并入用户数据供各页面展示。 + return { + ...data, + user: { + ...data.user, + rankIcon: data.rankIcon, + rankName: data.rankName, + }, + }; }; export const getProvinceData = () => { @@ -270,13 +279,15 @@ export const createPractiseAPI = (arrows, time, target) => { }); }; -export const createPractiseV2API = (arrows, time, target, deviceId) => { - return request("POST", "/practice/create-v2", { - shootNumber: arrows, - shootTime: time, - targetType: Number(target || 1) * 20, - deviceId, - }, PRACTICE_BASE_URL); +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) => { @@ -469,6 +480,15 @@ 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"); }; @@ -499,6 +519,25 @@ 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"); }; diff --git a/src/audioManager.js b/src/audioManager.js index fc7f899..1bf8f31 100644 --- a/src/audioManager.js +++ b/src/audioManager.js @@ -4,6 +4,10 @@ export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end"; export const audioFils = { tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3", 点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3", + 设备连接已断开: + "https://static.shelingxingqiu.com/shootmini/static/audio/%E8%AE%BE%E5%A4%87%E8%BF%9E%E6%8E%A5%E5%B7%B2%E6%96%AD%E5%BC%80.MP3", + 设备已连接: + "https://static.shelingxingqiu.com/shootmini/static/audio/%E8%AE%BE%E5%A4%87%E5%B7%B2%E8%BF%9E%E6%8E%A5.MP3", "20CM全环靶": "https://static.shelingxingqiu.com/shootmini/static/audio/20CM%E5%85%A8%E7%8E%AF%E9%9D%B6-%E6%97%A0%E6%95%88.mp3", "40CM全环靶": "https://static.shelingxingqiu.com/shootmini/static/audio/40CM%E5%85%A8%E7%8E%AF%E9%9D%B6-%E6%97%A0%E6%95%88.mp3", // 激光已校准: @@ -16,6 +20,8 @@ export const audioFils = { "https://static.shelingxingqiu.com/attachment/2025-09-17/dcutwrda0amn5kqr4j.mp3", 距离不足: "https://static.shelingxingqiu.com/attachment/2025-11-12/de6hr2faw28t0ianh0.mp3", + "未发现靶纸,请瞄准靶纸射箭": + "https://static.shelingxingqiu.com/shootmini/static/audio/%E6%9C%AA%E5%8F%91%E7%8E%B0%E9%9D%B6%E7%BA%B8%EF%BC%8C%E8%AF%B7%E7%9E%84%E5%87%86%E9%9D%B6%E7%BA%B8%E5%B0%84%E7%AE%AD.MP3", 轮到你了: "https://static.shelingxingqiu.com/attachment/2025-09-17/dcutzdrn4lxcpv8aqr.mp3", 第一轮: @@ -89,6 +95,8 @@ export const audioFils = { "https://static.shelingxingqiu.com/attachment/2025-11-13/de7kzzllq0futwynso.mp3", 练习开始: "https://static.shelingxingqiu.com/attachment/2025-11-14/de88w0lmmt43nnfmoi.mp3", + 练习结束: + "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3", 射箭声音: "https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3", 命中: @@ -100,8 +108,11 @@ const AUDIO_WARM_CONCURRENCY = 3; const AUDIO_WARM_RETRIES = 1; const AUDIO_WARM_PRIORITY_KEYS = [ "点击按钮", + "设备连接已断开", + "设备已连接", "比赛开始", "练习开始", + "练习结束", "请开始射击", "轮到你了", "比赛结束", @@ -113,6 +124,7 @@ const AUDIO_WARM_PRIORITY_KEYS = [ "请红方射箭", "距离合格", "距离不足", + "未发现靶纸,请瞄准靶纸射箭", "未上靶", "X环", ]; diff --git a/src/canvas.js b/src/canvas.js index 7c5b66b..853fae6 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -635,7 +635,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, @@ -657,7 +657,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, @@ -706,22 +706,39 @@ export async function sharePractiseData(canvasId, type, user, data) { ); const bgImg = await loadCanvasImage(canvas, bgImgSrc); - const avatarImgPromise = loadImage(user.avatar).then((path) => - loadCanvasImage(canvas, path) - ); - const lvlImgPromise = loadImage(user.lvlImage).then((path) => - loadCanvasImage(canvas, path) - ); + // 头像与段位框属于装饰图片,缺失时使用兜底或跳过,避免阻断分享。 + 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; + }; - let titleImageSrc = "../static/first-try-title.png"; + const avatarImgPromise = loadProfileImage( + user?.avatar, + "/static/user-icon.png", + "avatar" + ); + const lvlImgPromise = loadProfileImage(user?.lvlImage, "", "level"); + + let titleImageSrc = "/static/first-try-title.png"; if (type == 2) { - 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([ @@ -738,8 +755,8 @@ export async function sharePractiseData(canvasId, type, user, data) { ctx.drawImage(bgImg, 0, 0, width, height); - drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20); - ctx.drawImage(lvlImg, 12, 15, 42, 42); + if (avatarImg) drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20); + if (lvlImg) ctx.drawImage(lvlImg, 12, 15, 42, 42); renderText(ctx, user.nickName, 13, "#fff", 58, 34); renderRankTitle(ctx, user.lvlName); diff --git a/src/components/AppBackground.vue b/src/components/AppBackground.vue index 00feb51..7feadea 100644 --- a/src/components/AppBackground.vue +++ b/src/components/AppBackground.vue @@ -57,6 +57,30 @@ const props = defineProps({ src="https://static.shelingxingqiu.com/shootmini/static/rank/rank-bg.png" mode="widthFix" /> + + + + .footer { - height: 120px; + /* height: 120px; */ + height: 190rpx; width: 100vw; - position: relative; + /* position: relative; */ + position: fixed; + bottom: 0; + left: 0; display: flex; justify-content: space-around; align-items: center; diff --git a/src/components/Avatar.vue b/src/components/Avatar.vue index dc8290b..470db77 100644 --- a/src/components/Avatar.vue +++ b/src/components/Avatar.vue @@ -1,5 +1,5 @@ @@ -225,4 +267,55 @@ const goCalibration = async () => { color: #666; opacity: 0.6; } + +.audio-progress { + z-index: 999; + width: 100vw; + height: 100vh; + position: fixed; + top: 0; + left: 0; + background: rgb(0 0 0 / 0.8); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.audio-progress > image:nth-child(1) { + width: 140rpx; + height: 150rpx; + margin-bottom: 20rpx; +} + +.audio-progress > view:nth-child(2) { + width: 380rpx; + height: 6rpx; + background: #595959; + border-radius: 4rpx; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; +} + +.audio-progress > view:nth-child(2) > view { + width: 100%; + min-height: 6rpx; + background: #ffe431; + border-radius: 4rpx; +} + +.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; +} diff --git a/src/components/ScorePanel2.vue b/src/components/ScorePanel2.vue index bdd6c04..c12addd 100644 --- a/src/components/ScorePanel2.vue +++ b/src/components/ScorePanel2.vue @@ -5,13 +5,19 @@ const props = defineProps({ default: () => [], }, }); -const getSum = (a, b, c) => { - const sum = (Number(a) || 0) + (Number(b) || 0) + (Number(c) || 0); - return sum > 0 ? sum + "环" : "-"; +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 roundsName = ["第一轮", "第二轮", "第三轮", "第四轮"]; -const getShowText = (arrow = {}) => { - return arrow.ring ? (arrow.ringX ? "X" : arrow.ring + "环") : "-"; +const getShowText = (arrow) => { + if (!arrow) return "-"; + return arrow.ringX ? "X" : `${Number(arrow.ring) || 0}环`; }; diff --git a/src/pages/team-battle/components/TestDistance.vue b/src/pages/team-battle/components/TestDistance.vue index c2b4b4c..372ab92 100644 --- a/src/pages/team-battle/components/TestDistance.vue +++ b/src/pages/team-battle/components/TestDistance.vue @@ -51,8 +51,13 @@ onBeforeUnmount(() => { async function onReceiveMessage(msg) { if (Array.isArray(msg)) return; if (msg.type === MESSAGETYPESV2.TestDistance) { - distance.value = Number((msg.shootData.distance / 100).toFixed(2)); - if (distance.value >= 5) audioManager.play("距离合格"); + const rawDistance = Number(msg.shootData?.distance); + distance.value = Number.isFinite(rawDistance) + ? Number((rawDistance / 100).toFixed(2)) + : 0; + if (rawDistance === 0) { + audioManager.play("未发现靶纸,请瞄准靶纸射箭"); + } else if (distance.value >= 5) audioManager.play("距离合格"); else audioManager.play("距离不足"); } } @@ -113,7 +118,7 @@ onBeforeUnmount(() => { - 具体正式比赛还有 + 距离正式比赛还有 {{ count }} diff --git a/src/pages/team-battle/index.vue b/src/pages/team-battle/index.vue index 7507c47..65998e4 100644 --- a/src/pages/team-battle/index.vue +++ b/src/pages/team-battle/index.vue @@ -1417,6 +1417,7 @@ onShow(() => { :latestShotFlash="latestShotFlash" :redTeam="redTeam" :blueTeam="blueTeam" + stable-shot-effect /> +import { computed } from "vue"; +import AppBackground from "@/components/AppBackground.vue"; +import Avatar from "@/components/Avatar.vue"; +import BowTarget from "./BowTarget.vue"; +import ScorePanel from "./ScorePanel.vue"; +import useStore from "@/store"; +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 && !isSvip.value); + +const props = defineProps({ + show: { + type: Boolean, + default: false, + }, + onClose: { + type: Function, + default: () => {}, + }, + arrows: { + type: Array, + default: () => [], + }, + total: { + type: Number, + default: 0, + }, +}); + + + + + diff --git a/src/pages/training/components/BowTarget.vue b/src/pages/training/components/BowTarget.vue new file mode 100644 index 0000000..550a43d --- /dev/null +++ b/src/pages/training/components/BowTarget.vue @@ -0,0 +1,954 @@ + + + + + diff --git a/src/pages/training/components/BubbleTip.vue b/src/pages/training/components/BubbleTip.vue new file mode 100644 index 0000000..4fdfdb6 --- /dev/null +++ b/src/pages/training/components/BubbleTip.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/src/pages/training/components/ScorePanel.vue b/src/pages/training/components/ScorePanel.vue new file mode 100644 index 0000000..d03814e --- /dev/null +++ b/src/pages/training/components/ScorePanel.vue @@ -0,0 +1,157 @@ + + + + diff --git a/src/pages/training/components/ScorePanel2.vue b/src/pages/training/components/ScorePanel2.vue new file mode 100644 index 0000000..fe96acd --- /dev/null +++ b/src/pages/training/components/ScorePanel2.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/src/pages/training/components/ScoreResult.vue b/src/pages/training/components/ScoreResult.vue new file mode 100644 index 0000000..135a62f --- /dev/null +++ b/src/pages/training/components/ScoreResult.vue @@ -0,0 +1,797 @@ + + + + + diff --git a/src/pages/training/components/ScreenHint.vue b/src/pages/training/components/ScreenHint.vue new file mode 100644 index 0000000..9ca8cb1 --- /dev/null +++ b/src/pages/training/components/ScreenHint.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/pages/training/components/ShootProgress.vue b/src/pages/training/components/ShootProgress.vue new file mode 100644 index 0000000..bba3816 --- /dev/null +++ b/src/pages/training/components/ShootProgress.vue @@ -0,0 +1,462 @@ + + + + + diff --git a/src/pages/training/components/TestDistance.vue b/src/pages/training/components/TestDistance.vue new file mode 100644 index 0000000..2c43ade --- /dev/null +++ b/src/pages/training/components/TestDistance.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/src/pages/training/components/TrainingDifficultyBadge.vue b/src/pages/training/components/TrainingDifficultyBadge.vue new file mode 100644 index 0000000..5398582 --- /dev/null +++ b/src/pages/training/components/TrainingDifficultyBadge.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/src/pages/training/components/TrainingDifficultyPreviewCard.vue b/src/pages/training/components/TrainingDifficultyPreviewCard.vue new file mode 100644 index 0000000..9271672 --- /dev/null +++ b/src/pages/training/components/TrainingDifficultyPreviewCard.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/src/pages/training/components/TrainingDifficultyStartButton.vue b/src/pages/training/components/TrainingDifficultyStartButton.vue new file mode 100644 index 0000000..164d9b4 --- /dev/null +++ b/src/pages/training/components/TrainingDifficultyStartButton.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/src/pages/training/difficulty.vue b/src/pages/training/difficulty.vue new file mode 100644 index 0000000..fb6b9d2 --- /dev/null +++ b/src/pages/training/difficulty.vue @@ -0,0 +1,904 @@ + + + + + diff --git a/src/pages/training/index.vue b/src/pages/training/index.vue new file mode 100644 index 0000000..3687fe0 --- /dev/null +++ b/src/pages/training/index.vue @@ -0,0 +1,983 @@ + + + + + diff --git a/src/pages/training/practise-one.vue b/src/pages/training/practise-one.vue new file mode 100644 index 0000000..d91e292 --- /dev/null +++ b/src/pages/training/practise-one.vue @@ -0,0 +1,1566 @@ + + + + + diff --git a/src/pages/user.vue b/src/pages/user.vue index e123964..9589d27 100644 --- a/src/pages/user.vue +++ b/src/pages/user.vue @@ -77,7 +77,7 @@ const buildVersion = typeof __BUILD_TIME__ !== 'undefined' ? __BUILD_TIME__ : ''