From 44a88a55e6e9e4cb3a547538eb93eb4cecf8fdf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=B2=A9?= Date: Wed, 4 Mar 2026 14:27:49 +0800 Subject: [PATCH 1/6] =?UTF-8?q?JLBA202505130001=5F=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=90=89=E6=9E=97=E9=93=B6=E8=A1=8C=E6=96=B0=E5=BB=BAAI?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=9F=B9=E8=AE=AD=E7=B3=BB=E7=BB=9F=E7=9A=84?= =?UTF-8?q?=E9=9C=80=E6=B1=82=5F=E8=A1=A5=E5=85=85=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=9C=AA=E6=8F=90=E4=BA=A4=E7=9A=84=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 8 +- src/components/examination/question.vue | 20 +- src/components/touch-btn/style/popup.scss | 5 + src/components/touch-btn/touch-btn.images.ts | 2 +- src/components/touch-btn/touch-btn.types.ts | 2 +- src/components/touch-btn/touch-btn.vue | 413 ++++++++++++------- src/pages/practice/index.vue | 35 +- 7 files changed, 328 insertions(+), 157 deletions(-) diff --git a/.env.development b/.env.development index 1af6108b..ee2d52e3 100644 --- a/.env.development +++ b/.env.development @@ -4,15 +4,13 @@ ENV = 'development' -VITE_APP_BASE_API_Url = 'https://aits.jlbank.com.cn:7001' +#VITE_APP_BASE_API_Url = 'https://aits.jlbank.com.cn:7001' -#VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7001' - +VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7001' # VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7002' -# VITE_APP_BASE_API_Url = 'http://192.168.247.200' # VITE_APP_BASE_API_Url = 'http://aitscdn.jlbank.com.cn:7001' -# VITE_APP_BASE_API_Url = 'http://192.168.108.129' + # dev # DEV diff --git a/src/components/examination/question.vue b/src/components/examination/question.vue index a9ab2d4a..fe340a49 100644 --- a/src/components/examination/question.vue +++ b/src/components/examination/question.vue @@ -169,7 +169,7 @@ setTimeout(() => { textShow.value = true; textLoading.value = false; - }, 500); + }, 400); } else { textShow.value = false; } @@ -222,7 +222,7 @@ console.log('加载声音', props.dataInfo); if (newVal === '' && props.dataInfo.ossKey) { // 去下载oss内容 - console.log('真下载oss内容'); + console.log('真下载oss内容', props.dataInfo.ossKey); downloadFile(props.dataInfo.ossKey).then((res) => { voiceTime.value = ''; innerAudio.setAudioSrc(res.tempFilePath); @@ -236,6 +236,22 @@ }, { immediate: true } ); + watch( + () => props.dataInfo.ossKey, + (newVal) => { + if (props.dataInfo.voicePath === undefined && props.dataInfo.ossKey) { + console.log('真下载oss内容', props.dataInfo.ossKey); + downloadFile(props.dataInfo.ossKey).then((res) => { + voiceTime.value = ''; + props.dataInfo.voicePath = res.tempFilePath; + innerAudio.setAudioSrc(res.tempFilePath); + }); + } else if (newVal === undefined) { + innerAudio.stopAudio(); + } + }, + { immediate: true } + ); }); onUnload(() => { // 1. 停止当前播放(优先处理,避免音频继续播放) diff --git a/src/components/touch-btn/style/popup.scss b/src/components/touch-btn/style/popup.scss index e21164c4..94b35bc4 100644 --- a/src/components/touch-btn/style/popup.scss +++ b/src/components/touch-btn/style/popup.scss @@ -61,6 +61,7 @@ .circle-bg-box { animation: moveBottomOut 0.3s ease-out forwards; } + } // 仅语音 &.voice-only { @@ -143,6 +144,10 @@ visibility: visible; // 激活后可见 animation: fadeIn 0.4s ease-out 0.1s forwards; } + // 仅文字模式,隐藏发送原语音按钮 + &.text-only>.trans-btn{ + visibility: hidden; + } .transVoiceIng { width: 154rpx; height: 154rpx; diff --git a/src/components/touch-btn/touch-btn.images.ts b/src/components/touch-btn/touch-btn.images.ts index c6f901a8..d59e587e 100644 --- a/src/components/touch-btn/touch-btn.images.ts +++ b/src/components/touch-btn/touch-btn.images.ts @@ -17,7 +17,7 @@ export const icKeyboardSvg = () : string => { }; export const iconMicSvg = () : string => { const svgXml = ` - + `; return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgXml)}`; }; diff --git a/src/components/touch-btn/touch-btn.types.ts b/src/components/touch-btn/touch-btn.types.ts index 7db8a762..71878349 100644 --- a/src/components/touch-btn/touch-btn.types.ts +++ b/src/components/touch-btn/touch-btn.types.ts @@ -44,7 +44,7 @@ export type TouchBtnEmits = { * @param voiceData - 语音相关数据(包含ossKey、识别文本等核心字段) * @param duration - 录音时长(单位:毫秒),用于校验最短录音时长 */ - submitVoice: [params: { ossKey: string; text: string }]; + submitVoice: [params: { ossKey: string; text: string, recordId:string }]; /** * 提交文字答案(用户确认识别结果/手动编辑后触发) * @param answerText - 最终提交的文字答案内容 diff --git a/src/components/touch-btn/touch-btn.vue b/src/components/touch-btn/touch-btn.vue index cfd17f89..22fae1b3 100644 --- a/src/components/touch-btn/touch-btn.vue +++ b/src/components/touch-btn/touch-btn.vue @@ -1,52 +1,84 @@ \ No newline at end of file + diff --git a/src/pages/practice/index.vue b/src/pages/practice/index.vue index f3f1ab39..c5b40cdd 100644 --- a/src/pages/practice/index.vue +++ b/src/pages/practice/index.vue @@ -67,7 +67,7 @@ - + /> --> + { + const topic = topicList[questionNumber.value]; + console.log('录音组件提交中转圈', topic.es_status); + if (topic.es_status === '00') return; + topic.es_status = '00'; // 00转圈 + } + // 新录音组件语音提交 + const submitVoice = (voiceObj) => { + console.log('新录音组件语音提交'); + const topic = topicList[questionNumber.value]; + touchBtnRef.value?.clearInputText(); // 清除发送框数据 + const _content = voiceObj.text; + if (_content === '') { + topic.es_status = ''; // 取消转圈 + return common.msg('未识别到文字'); + } else { + topic.es_status = '02'; + topic.my_answer = { + anserResult: _content, + ossKey: voiceObj.ossKey + }; + } + + } // 录音组件提交 const touchBtnSubmit = (type, submitObj) => { const topic = topicList[questionNumber.value]; From e475378b0c1431ca032d77334078934f3a56b235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=B2=A9?= Date: Wed, 4 Mar 2026 14:34:19 +0800 Subject: [PATCH 2/6] =?UTF-8?q?JLBA202505130001=5F=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=90=89=E6=9E=97=E9=93=B6=E8=A1=8C=E6=96=B0=E5=BB=BAAI?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=9F=B9=E8=AE=AD=E7=B3=BB=E7=BB=9F=E7=9A=84?= =?UTF-8?q?=E9=9C=80=E6=B1=82=5F=E6=96=87=E4=BB=B6=E6=94=B9=E6=88=90?= =?UTF-8?q?=E5=B0=8F=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/asProtocolCodec.ts | 410 ++++++++++++++++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 src/common/asProtocolCodec.ts diff --git a/src/common/asProtocolCodec.ts b/src/common/asProtocolCodec.ts new file mode 100644 index 00000000..dd6e501d --- /dev/null +++ b/src/common/asProtocolCodec.ts @@ -0,0 +1,410 @@ +// 导入 GZIP 处理库(浏览器/Node.js 通用,需提前安装:npm install pako @types/pako) +// import * as pako from "pako"; +import { TextDecoder } from 'text-encoding'; +/** + * 协议常量类:存储协议核心配置(不可修改,确保前后端一致) + */ +export class ProtocolConst { + /** 协议版本:0~15(4位,存储在字节0低4位) */ + static readonly PROTOCOL_VERSION = 0b0001; + + /** 头部固定长度:8字节(字节0~7,结构严格定义,不可修改) */ + static readonly HEADER_SIZE = 8; + + /** + * 最大包体大小:16MB(3字节长度最大支持0xFFFFFF=16777215字节≈16MB) + * 4-6字节存储(24位),最大支持16MB,满足大部分场景且避免长度字段冗余 + */ + static readonly MAX_BODY_SIZE = 0xFFFFFF; // 16777215字节 ≈16MB + + /** 字符串编码格式:UTF-8(统一前后端字符串编解码,避免乱码) */ + static readonly STRING_ENCODING = "utf-8" as const; +} + +/** + * 消息类型枚举(4位,存储在字节0高4位,0~15范围) + * 二进制标识,统一编码风格 + */ +export enum MessageType { + PING = 0b0001, // 心跳消息(支持空包体) + AUDIO_DATA = 0b0010, // 纯音频数据 + TEXT_MESSAGE = 0b0011, // 大模型回复的文本消息 + CONTROL_CMD = 0b0100, // 控制指令 + IDENTITY = 0b0101, // 身份校验包json格式 + ERROR = 0b0110, // 错误信息json格式 + TIP_MESSAGE = 0b0111 // 大模型生成的回答提示 + // 预留12种类型用于扩展(0b0100 ~ 0b1111) +} + +/** + * 控制消息枚举 + * 二进制标识,统一编码风格 + */ +export enum ControlCode { + FINISH_PUSH = 0b0001, // 本轮tts推流结束 + FINISH_PLAY = 0b0010, // 语音播放结束(发后端) + AI_ANSWER_BEGIN = 0b0011, // 本轮AI回答的文本内容开始 + AI_ANSWER_OVER = 0b0100, // 本轮AI回答的文本内容已经全部返回 + AI_CLUE = 0b0101, // 需要AI提示命令(发后端) + AI_CLUE_OVER = 0b0110, // AI提示命令已经全部返回 + FRONT_TO_SERVER_OVER = 0b0111, // 语音按钮已经抬起 + SERVER_TO_FRONT_OVER = 0b1000, // 后端已经返回本次全部内容 +} + + + +/** + * 错误类型枚举 + * 二进制标识,统一编码风格 + */ +export enum ErrorType { + PING = 2, // 认证错误 + AUDIO_DATA = 0b0010, // 纯音频数据 + TEXT_MESSAGE = 0b0011, // 大模型回复的文本消息 + CONTROL_CMD = 0b0100, // 控制指令 + IDENTITY = 0b0101, // 身份校验包json格式 + ERROR = 0b0110, // 错误信息json格式 + TIP_MESSAGE = 0b0111 // 大模型生成的回答提示 + // 预留12种类型用于扩展(0b0100 ~ 0b1111) +} + +/** + * 序列化方式枚举(3位,存储在字节1高3位,1~8范围) + * 二进制标识,统一编码风格(1~8对应0b001~0b111) + */ +export enum SerializationType { + RAW = 0b001, // 原始二进制(1) + JSON = 0b010, // JSON 格式(2) + STRING = 0b011, // 直接字符串(3) + // 预留5种方式用于扩展(0b100 ~ 0b111) +} + +/** + * 压缩方式枚举(3位,存储在字节1中3位,1~8范围) + * 二进制标识,统一编码风格(1~8对应0b001~0b111) + */ +export enum CompressionType { + NONE = 0b001, // 无压缩(1,默认值) + GZIP = 0b010, // GZIP 压缩(2) + // 预留6种方式用于扩展(0b011 ~ 0b111) +} + +/** + * 控制指令枚举(配合 MessageType.CONTROL_CMD 使用) + */ +export enum ControlCommand { + START_PLAY = 0b0001, // 一句话播放开始 + FINISH_PLAY = 0b0010, // 一句话播放结束 +} + +export interface ControlBody { + type: ControlCode; // type 是必选属性,关联你的 ControlCode 枚举 + [key: string]: any; // 索引签名:兼容后端返回的任意动态属性(txt/ossKey 等) +} + +/** + * 解包返回结果接口 + */ +export interface UnpackedResult { + msgType : MessageType; + msgTypeName : keyof typeof MessageType; + serialization : SerializationType; + serializationName : keyof typeof SerializationType; + compression : CompressionType; + compressionName : keyof typeof CompressionType; + sequence : number; // 消息顺序号(0~65535,默认0) + body : Uint8Array | string | object | unknown[] | null | ControlBody; // PING 消息可能返回 null +} + +/** + * 打包入参类型别名(支持 PING 消息传入 null) + */ +type PackBody = Uint8Array | string | object | unknown[] | null; +type OptionalSerialization = SerializationType | null | undefined; + +/** + * 协议编解码工具类(支持 PING 消息空包体) + */ +export class ProtocolCodec { + /** + * 打包协议包 + * @param msgType 消息类型(二进制枚举,0~15) + * @param body 业务数据(PING 消息可传 null/undefined,其他类型必填) + * @param sequence 消息顺序号(0~65535,可选,默认0) + * @param serialization 序列化方式(二进制枚举,1~8,可选,自动推导) + * @param compression 压缩方式(二进制枚举,1~8,可选,默认NONE=0b001) + * @returns 完整协议包 + * @throws 类型错误、范围错误、包体过大等异常 + */ + static pack( + msgType : MessageType, + body : PackBody = null, // 默认为 null,支持 PING 消息空包体 + sequence : number = 0, // 可选参数,默认0 + serialization : OptionalSerialization = null, + compression : CompressionType = CompressionType.NONE + ) : Uint8Array { + // 校验顺序号范围(0~65535) + if (!Number.isInteger(sequence) || sequence < 0 || sequence > 0xFFFF) { + throw new RangeError(`消息顺序号必须是0~65535的整数,当前传入:${sequence}`); + } + + // 特殊处理:PING 消息允许空包体,强制 RAW 序列化(空二进制) + if (msgType === MessageType.PING) { + // PING 消息忽略传入的序列化方式,强制使用 RAW(空二进制最高效) + serialization = SerializationType.RAW; + // 若传入空包体,统一处理为空 Uint8Array + // body = body === null || body === undefined ? new Uint8Array(0) : body; + // PING 消息仅支持空包体或 Uint8Array(防止误传其他类型) + if (!(body instanceof Uint8Array)) { + throw new TypeError(`PING 消息仅支持空包体或 Uint8Array 类型,当前传入:${typeof body}`); + } + } else { + // 非 PING 消息:包体必填 + if (body === null || body === undefined) { + throw new TypeError(`非 PING 消息(${MessageType[msgType]})包体不能为空`); + } + } + + // 1. 自动推导序列化方式(非 PING 消息) + if (serialization === null || serialization === undefined && msgType !== MessageType.PING) { + if (msgType === MessageType.AUDIO_DATA) { + serialization = SerializationType.RAW; + } else if (msgType === MessageType.TEXT_MESSAGE) { + serialization = SerializationType.STRING; + } else if (msgType === MessageType.CONTROL_CMD) { + serialization = SerializationType.JSON; + } else if (msgType === MessageType.IDENTITY) { + serialization = SerializationType.JSON; + } else if (msgType === MessageType.ERROR) { + serialization = SerializationType.JSON; + } else { + throw new Error(`不支持的消息类型:${MessageType[msgType]}(值:${msgType})`); + } + } + + // 2. 校验枚举值范围(3位存储,1~8即0b001~0b111) + if (serialization < 0b001 || serialization > 0b111) { + throw new RangeError(`序列化方式必须在1~8(0b001~0b111)范围内,当前传入:${serialization}(0b${serialization.toString(2).padStart(3, '0')})`); + } + if (compression < 0b001 || compression > 0b111) { + throw new RangeError(`压缩方式必须在1~8(0b001~0b111)范围内,当前传入:${compression}(0b${compression.toString(2).padStart(3, '0')})`); + } + + // 3. 序列化包体 + let serializedBody : Uint8Array; + const textEncoder = new TextEncoder(); + + switch (serialization) { + case SerializationType.RAW: + // RAW 序列化:支持 Uint8Array(PING 消息可能是空 Uint8Array) + // if (!(body instanceof Uint8Array)) { + // throw new TypeError(`RAW 序列化要求 body 必须是 Uint8Array 类型,当前传入:${typeof body}`); + // } + serializedBody = body instanceof Uint8Array ? body : new Uint8Array(body as ArrayBuffer); + break; + + case SerializationType.STRING: + // STRING 序列化:必须传入字符串(非 PING 消息已校验非空) + serializedBody = textEncoder.encode(body as string); + break; + case SerializationType.JSON: + // 断言:body 是 string 或 object + if (typeof body === 'string') { + serializedBody = textEncoder.encode(body); + } else { + serializedBody = textEncoder.encode(JSON.stringify(body)); + } + break; + + default: + throw new Error(`不支持的序列化方式:${SerializationType[serialization]}(值:${serialization},0b${serialization.toString(2).padStart(3, '0')})`); + } + + // 4. 压缩:暂不支持,直接赋值 + const compressedBody = serializedBody; + + // 5. 校验包体大小(24位长度最大支持0xFFFFFF=16777215字节) + const bodyLen = compressedBody.length; + if (bodyLen > ProtocolConst.MAX_BODY_SIZE) { + throw new Error( + `包体过大(${bodyLen}字节),最大支持${ProtocolConst.MAX_BODY_SIZE}字节(≈16MB)` + ); + } + + // 6. 构造头部(8字节,按最新结构) + const header = new Uint8Array(ProtocolConst.HEADER_SIZE); + + // 字节0:消息类型(高4位) + 协议版本(低4位) + header[0] = ((msgType & 0x0F) << 4) | (ProtocolConst.PROTOCOL_VERSION & 0x0F); + + // 字节1:序列化方式(高3位) + 压缩方式(中3位) + 保留位(低2位,填0) + header[1] = ((serialization & 0x07) << 5) | ((compression & 0x07) << 2) | 0x00; + + // 字节2~3:消息顺序号(16位大端序,0~65535) + header[2] = (sequence >> 8) & 0xFF; // 顺序号高8位 + header[3] = sequence & 0xFF; // 顺序号低8位 + + // 字节4~6:消息体长度(24位大端序,0~0xFFFFFF) + header[4] = (bodyLen >> 16) & 0xFF; // 长度高8位 + header[5] = (bodyLen >> 8) & 0xFF; // 长度中8位 + header[6] = bodyLen & 0xFF; // 长度低8位 + + // 字节7:保留位(固定填0x00) + header[7] = 0x00; + + // 7. 拼接头部和包体 + const totalLen = ProtocolConst.HEADER_SIZE + bodyLen; + const packet = new Uint8Array(totalLen); + packet.set(header, 0); + packet.set(compressedBody, ProtocolConst.HEADER_SIZE); + + return packet; + } + + /** + * 解包协议包 + * @param packet 完整协议包 + * @returns 结构化解包结果(含顺序号) + * @throws 各种解析异常 + */ + static unpack(packet : Uint8Array | ArrayBuffer) : UnpackedResult { + const uint8Packet = packet instanceof ArrayBuffer + ? new Uint8Array(packet) + : packet; + + // 1. 校验包长度(至少8字节头部) + if (uint8Packet.length < ProtocolConst.HEADER_SIZE) { + throw new Error( + `包长度过短(${uint8Packet.length}字节),至少需要${ProtocolConst.HEADER_SIZE}字节头部` + ); + } + + // 2. 拆分头部和包体 + // const header = uint8Packet.subarray(0, ProtocolConst.HEADER_SIZE); + // const bodyBuffer = uint8Packet.subarray(ProtocolConst.HEADER_SIZE); + let header: Uint8Array, bodyBuffer: Uint8Array; + try { + header = uint8Packet.subarray(0, ProtocolConst.HEADER_SIZE); + bodyBuffer = uint8Packet.subarray(ProtocolConst.HEADER_SIZE); + } catch (e) { + // 捕获subarray调用异常,并抛出更易理解的错误信息 + const error = e as Error; + throw new Error( + `解包拆分头部/包体失败:${error.message},原始包类型=${packet.constructor.name},长度=${uint8Packet.length}字节` + ); + } + // 3. 解析头部字段 + // 字节0:消息类型(高4位) + 协议版本(低4位) + const byte0 = header[0]; + const msgType = (byte0 >> 4) & 0x0F; // 消息类型(0~15) + const version = byte0 & 0x0F; // 协议版本(0~15) + + // 校验消息类型 + if (!Object.values(MessageType).includes(msgType as MessageType)) { + throw new Error(`非法消息类型:${msgType}(0b${msgType.toString(2).padStart(4, '0')})`); + } + + // 校验版本 + if (version !== ProtocolConst.PROTOCOL_VERSION) { + throw new Error( + `协议版本不匹配:收到v${version}(0b${version.toString(2).padStart(4, '0')}),当前支持v${ProtocolConst.PROTOCOL_VERSION}(0b${ProtocolConst.PROTOCOL_VERSION.toString(2).padStart(4, '0')})` + ); + } + + // 字节1:序列化方式(高3位) + 压缩方式(中3位) + 保留位(低2位) + const byte1 = header[1]; + const serialization = (byte1 >> 5) & 0x07; // 高3位(1~8) + const compression = (byte1 >> 2) & 0x07; // 中3位(1~8) + // 保留位:(byte1 & 0x03),暂不处理 + + // 校验序列化方式 + if (!Object.values(SerializationType).includes(serialization as SerializationType)) { + throw new Error(`非法序列化方式:${serialization}(0b${serialization.toString(2).padStart(3, '0')})`); + } + + // 校验压缩方式 + if (!Object.values(CompressionType).includes(compression as CompressionType)) { + throw new Error(`非法压缩方式:${compression}(0b${compression.toString(2).padStart(3, '0')})`); + } + + // 字节2~3:消息顺序号(16位大端序) + const sequence = (header[2] << 8) | header[3]; // 0~65535 + + // 字节4~6:消息体长度(24位大端序),字节7:保留位(忽略) + const bodyLen = (header[4] << 16) | (header[5] << 8) | header[6]; + + // 校验包体长度(空包体时 bodyBuffer.length 应为0) + if (bodyBuffer.length !== bodyLen) { + throw new Error( + `包体长度不匹配:头部声明${bodyLen}字节,实际接收${bodyBuffer.length}字节` + ); + } + + // 4. 解压包体 + let decompressedBody : Uint8Array; + if (compression === CompressionType.NONE) { + decompressedBody = bodyBuffer; + } else if (compression === CompressionType.GZIP) { + // 若需启用GZIP,取消注释下方代码 + // try { + // decompressedBody = pako.ungzip(bodyBuffer); + // } catch (e) { + // throw new Error(`GZIP 解压失败:${(e as Error).message}`); + // } + throw new Error("GZIP 解压暂未启用,请导入pako库并取消对应代码注释"); + } else { + throw new Error(`不支持的压缩方式:${CompressionType[compression]}(值:${compression},0b${compression.toString(2).padStart(3, '0')})`); + } + + // 5. 反序列化包体(PING 消息空包体返回 null) + let body : UnpackedResult["body"]; + const textDecoder = new TextDecoder(); + + // 特殊处理:空包体(PING 消息常见)返回 null + if (decompressedBody.length === 0) { + body = null; + } else { + switch (serialization) { + case SerializationType.RAW: + body = decompressedBody; + break; + + case SerializationType.STRING: + try { + body = textDecoder.decode(decompressedBody); + } catch (e) { + throw new Error(`STRING 反序列化失败:UTF-8 解码错误`); + } + break; + + case SerializationType.JSON: + try { + const jsonStr = textDecoder.decode(decompressedBody); + body = JSON.parse(jsonStr); + } catch (e) { + if (e instanceof SyntaxError) { + throw new Error(`JSON 反序列化失败:格式错误(${(e as Error).message})`); + } else { + throw new Error(`JSON 反序列化失败:${(e as Error).message}`); + } + } + break; + + default: + throw new Error(`不支持的序列化方式:${SerializationType[serialization]}(值:${serialization},0b${serialization.toString(2).padStart(3, '0')})`); + } + } + + // 6. 返回解包结果 + return { + msgType: msgType as MessageType, + msgTypeName: MessageType[msgType] as keyof typeof MessageType, + serialization: serialization as SerializationType, + serializationName: SerializationType[serialization] as keyof typeof SerializationType, + compression: compression as CompressionType, + compressionName: CompressionType[compression] as keyof typeof CompressionType, + sequence: sequence, + body: body, + }; + } +} \ No newline at end of file From c1333d4863c3f0bdbc39c7e2fa7415c5406e8d17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=B2=A9?= Date: Wed, 4 Mar 2026 14:35:37 +0800 Subject: [PATCH 3/6] =?UTF-8?q?JLBA202505130001=5F=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=90=89=E6=9E=97=E9=93=B6=E8=A1=8C=E6=96=B0=E5=BB=BAAI?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=9F=B9=E8=AE=AD=E7=B3=BB=E7=BB=9F=E7=9A=84?= =?UTF-8?q?=E9=9C=80=E6=B1=82=5F=E6=96=87=E4=BB=B6=E6=94=B9=E6=88=90?= =?UTF-8?q?=E5=B0=8F=E5=86=991?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/asProtocolCodec.ts | 410 ---------------------------------- 1 file changed, 410 deletions(-) delete mode 100644 src/common/asProtocolCodec.ts diff --git a/src/common/asProtocolCodec.ts b/src/common/asProtocolCodec.ts deleted file mode 100644 index dd6e501d..00000000 --- a/src/common/asProtocolCodec.ts +++ /dev/null @@ -1,410 +0,0 @@ -// 导入 GZIP 处理库(浏览器/Node.js 通用,需提前安装:npm install pako @types/pako) -// import * as pako from "pako"; -import { TextDecoder } from 'text-encoding'; -/** - * 协议常量类:存储协议核心配置(不可修改,确保前后端一致) - */ -export class ProtocolConst { - /** 协议版本:0~15(4位,存储在字节0低4位) */ - static readonly PROTOCOL_VERSION = 0b0001; - - /** 头部固定长度:8字节(字节0~7,结构严格定义,不可修改) */ - static readonly HEADER_SIZE = 8; - - /** - * 最大包体大小:16MB(3字节长度最大支持0xFFFFFF=16777215字节≈16MB) - * 4-6字节存储(24位),最大支持16MB,满足大部分场景且避免长度字段冗余 - */ - static readonly MAX_BODY_SIZE = 0xFFFFFF; // 16777215字节 ≈16MB - - /** 字符串编码格式:UTF-8(统一前后端字符串编解码,避免乱码) */ - static readonly STRING_ENCODING = "utf-8" as const; -} - -/** - * 消息类型枚举(4位,存储在字节0高4位,0~15范围) - * 二进制标识,统一编码风格 - */ -export enum MessageType { - PING = 0b0001, // 心跳消息(支持空包体) - AUDIO_DATA = 0b0010, // 纯音频数据 - TEXT_MESSAGE = 0b0011, // 大模型回复的文本消息 - CONTROL_CMD = 0b0100, // 控制指令 - IDENTITY = 0b0101, // 身份校验包json格式 - ERROR = 0b0110, // 错误信息json格式 - TIP_MESSAGE = 0b0111 // 大模型生成的回答提示 - // 预留12种类型用于扩展(0b0100 ~ 0b1111) -} - -/** - * 控制消息枚举 - * 二进制标识,统一编码风格 - */ -export enum ControlCode { - FINISH_PUSH = 0b0001, // 本轮tts推流结束 - FINISH_PLAY = 0b0010, // 语音播放结束(发后端) - AI_ANSWER_BEGIN = 0b0011, // 本轮AI回答的文本内容开始 - AI_ANSWER_OVER = 0b0100, // 本轮AI回答的文本内容已经全部返回 - AI_CLUE = 0b0101, // 需要AI提示命令(发后端) - AI_CLUE_OVER = 0b0110, // AI提示命令已经全部返回 - FRONT_TO_SERVER_OVER = 0b0111, // 语音按钮已经抬起 - SERVER_TO_FRONT_OVER = 0b1000, // 后端已经返回本次全部内容 -} - - - -/** - * 错误类型枚举 - * 二进制标识,统一编码风格 - */ -export enum ErrorType { - PING = 2, // 认证错误 - AUDIO_DATA = 0b0010, // 纯音频数据 - TEXT_MESSAGE = 0b0011, // 大模型回复的文本消息 - CONTROL_CMD = 0b0100, // 控制指令 - IDENTITY = 0b0101, // 身份校验包json格式 - ERROR = 0b0110, // 错误信息json格式 - TIP_MESSAGE = 0b0111 // 大模型生成的回答提示 - // 预留12种类型用于扩展(0b0100 ~ 0b1111) -} - -/** - * 序列化方式枚举(3位,存储在字节1高3位,1~8范围) - * 二进制标识,统一编码风格(1~8对应0b001~0b111) - */ -export enum SerializationType { - RAW = 0b001, // 原始二进制(1) - JSON = 0b010, // JSON 格式(2) - STRING = 0b011, // 直接字符串(3) - // 预留5种方式用于扩展(0b100 ~ 0b111) -} - -/** - * 压缩方式枚举(3位,存储在字节1中3位,1~8范围) - * 二进制标识,统一编码风格(1~8对应0b001~0b111) - */ -export enum CompressionType { - NONE = 0b001, // 无压缩(1,默认值) - GZIP = 0b010, // GZIP 压缩(2) - // 预留6种方式用于扩展(0b011 ~ 0b111) -} - -/** - * 控制指令枚举(配合 MessageType.CONTROL_CMD 使用) - */ -export enum ControlCommand { - START_PLAY = 0b0001, // 一句话播放开始 - FINISH_PLAY = 0b0010, // 一句话播放结束 -} - -export interface ControlBody { - type: ControlCode; // type 是必选属性,关联你的 ControlCode 枚举 - [key: string]: any; // 索引签名:兼容后端返回的任意动态属性(txt/ossKey 等) -} - -/** - * 解包返回结果接口 - */ -export interface UnpackedResult { - msgType : MessageType; - msgTypeName : keyof typeof MessageType; - serialization : SerializationType; - serializationName : keyof typeof SerializationType; - compression : CompressionType; - compressionName : keyof typeof CompressionType; - sequence : number; // 消息顺序号(0~65535,默认0) - body : Uint8Array | string | object | unknown[] | null | ControlBody; // PING 消息可能返回 null -} - -/** - * 打包入参类型别名(支持 PING 消息传入 null) - */ -type PackBody = Uint8Array | string | object | unknown[] | null; -type OptionalSerialization = SerializationType | null | undefined; - -/** - * 协议编解码工具类(支持 PING 消息空包体) - */ -export class ProtocolCodec { - /** - * 打包协议包 - * @param msgType 消息类型(二进制枚举,0~15) - * @param body 业务数据(PING 消息可传 null/undefined,其他类型必填) - * @param sequence 消息顺序号(0~65535,可选,默认0) - * @param serialization 序列化方式(二进制枚举,1~8,可选,自动推导) - * @param compression 压缩方式(二进制枚举,1~8,可选,默认NONE=0b001) - * @returns 完整协议包 - * @throws 类型错误、范围错误、包体过大等异常 - */ - static pack( - msgType : MessageType, - body : PackBody = null, // 默认为 null,支持 PING 消息空包体 - sequence : number = 0, // 可选参数,默认0 - serialization : OptionalSerialization = null, - compression : CompressionType = CompressionType.NONE - ) : Uint8Array { - // 校验顺序号范围(0~65535) - if (!Number.isInteger(sequence) || sequence < 0 || sequence > 0xFFFF) { - throw new RangeError(`消息顺序号必须是0~65535的整数,当前传入:${sequence}`); - } - - // 特殊处理:PING 消息允许空包体,强制 RAW 序列化(空二进制) - if (msgType === MessageType.PING) { - // PING 消息忽略传入的序列化方式,强制使用 RAW(空二进制最高效) - serialization = SerializationType.RAW; - // 若传入空包体,统一处理为空 Uint8Array - // body = body === null || body === undefined ? new Uint8Array(0) : body; - // PING 消息仅支持空包体或 Uint8Array(防止误传其他类型) - if (!(body instanceof Uint8Array)) { - throw new TypeError(`PING 消息仅支持空包体或 Uint8Array 类型,当前传入:${typeof body}`); - } - } else { - // 非 PING 消息:包体必填 - if (body === null || body === undefined) { - throw new TypeError(`非 PING 消息(${MessageType[msgType]})包体不能为空`); - } - } - - // 1. 自动推导序列化方式(非 PING 消息) - if (serialization === null || serialization === undefined && msgType !== MessageType.PING) { - if (msgType === MessageType.AUDIO_DATA) { - serialization = SerializationType.RAW; - } else if (msgType === MessageType.TEXT_MESSAGE) { - serialization = SerializationType.STRING; - } else if (msgType === MessageType.CONTROL_CMD) { - serialization = SerializationType.JSON; - } else if (msgType === MessageType.IDENTITY) { - serialization = SerializationType.JSON; - } else if (msgType === MessageType.ERROR) { - serialization = SerializationType.JSON; - } else { - throw new Error(`不支持的消息类型:${MessageType[msgType]}(值:${msgType})`); - } - } - - // 2. 校验枚举值范围(3位存储,1~8即0b001~0b111) - if (serialization < 0b001 || serialization > 0b111) { - throw new RangeError(`序列化方式必须在1~8(0b001~0b111)范围内,当前传入:${serialization}(0b${serialization.toString(2).padStart(3, '0')})`); - } - if (compression < 0b001 || compression > 0b111) { - throw new RangeError(`压缩方式必须在1~8(0b001~0b111)范围内,当前传入:${compression}(0b${compression.toString(2).padStart(3, '0')})`); - } - - // 3. 序列化包体 - let serializedBody : Uint8Array; - const textEncoder = new TextEncoder(); - - switch (serialization) { - case SerializationType.RAW: - // RAW 序列化:支持 Uint8Array(PING 消息可能是空 Uint8Array) - // if (!(body instanceof Uint8Array)) { - // throw new TypeError(`RAW 序列化要求 body 必须是 Uint8Array 类型,当前传入:${typeof body}`); - // } - serializedBody = body instanceof Uint8Array ? body : new Uint8Array(body as ArrayBuffer); - break; - - case SerializationType.STRING: - // STRING 序列化:必须传入字符串(非 PING 消息已校验非空) - serializedBody = textEncoder.encode(body as string); - break; - case SerializationType.JSON: - // 断言:body 是 string 或 object - if (typeof body === 'string') { - serializedBody = textEncoder.encode(body); - } else { - serializedBody = textEncoder.encode(JSON.stringify(body)); - } - break; - - default: - throw new Error(`不支持的序列化方式:${SerializationType[serialization]}(值:${serialization},0b${serialization.toString(2).padStart(3, '0')})`); - } - - // 4. 压缩:暂不支持,直接赋值 - const compressedBody = serializedBody; - - // 5. 校验包体大小(24位长度最大支持0xFFFFFF=16777215字节) - const bodyLen = compressedBody.length; - if (bodyLen > ProtocolConst.MAX_BODY_SIZE) { - throw new Error( - `包体过大(${bodyLen}字节),最大支持${ProtocolConst.MAX_BODY_SIZE}字节(≈16MB)` - ); - } - - // 6. 构造头部(8字节,按最新结构) - const header = new Uint8Array(ProtocolConst.HEADER_SIZE); - - // 字节0:消息类型(高4位) + 协议版本(低4位) - header[0] = ((msgType & 0x0F) << 4) | (ProtocolConst.PROTOCOL_VERSION & 0x0F); - - // 字节1:序列化方式(高3位) + 压缩方式(中3位) + 保留位(低2位,填0) - header[1] = ((serialization & 0x07) << 5) | ((compression & 0x07) << 2) | 0x00; - - // 字节2~3:消息顺序号(16位大端序,0~65535) - header[2] = (sequence >> 8) & 0xFF; // 顺序号高8位 - header[3] = sequence & 0xFF; // 顺序号低8位 - - // 字节4~6:消息体长度(24位大端序,0~0xFFFFFF) - header[4] = (bodyLen >> 16) & 0xFF; // 长度高8位 - header[5] = (bodyLen >> 8) & 0xFF; // 长度中8位 - header[6] = bodyLen & 0xFF; // 长度低8位 - - // 字节7:保留位(固定填0x00) - header[7] = 0x00; - - // 7. 拼接头部和包体 - const totalLen = ProtocolConst.HEADER_SIZE + bodyLen; - const packet = new Uint8Array(totalLen); - packet.set(header, 0); - packet.set(compressedBody, ProtocolConst.HEADER_SIZE); - - return packet; - } - - /** - * 解包协议包 - * @param packet 完整协议包 - * @returns 结构化解包结果(含顺序号) - * @throws 各种解析异常 - */ - static unpack(packet : Uint8Array | ArrayBuffer) : UnpackedResult { - const uint8Packet = packet instanceof ArrayBuffer - ? new Uint8Array(packet) - : packet; - - // 1. 校验包长度(至少8字节头部) - if (uint8Packet.length < ProtocolConst.HEADER_SIZE) { - throw new Error( - `包长度过短(${uint8Packet.length}字节),至少需要${ProtocolConst.HEADER_SIZE}字节头部` - ); - } - - // 2. 拆分头部和包体 - // const header = uint8Packet.subarray(0, ProtocolConst.HEADER_SIZE); - // const bodyBuffer = uint8Packet.subarray(ProtocolConst.HEADER_SIZE); - let header: Uint8Array, bodyBuffer: Uint8Array; - try { - header = uint8Packet.subarray(0, ProtocolConst.HEADER_SIZE); - bodyBuffer = uint8Packet.subarray(ProtocolConst.HEADER_SIZE); - } catch (e) { - // 捕获subarray调用异常,并抛出更易理解的错误信息 - const error = e as Error; - throw new Error( - `解包拆分头部/包体失败:${error.message},原始包类型=${packet.constructor.name},长度=${uint8Packet.length}字节` - ); - } - // 3. 解析头部字段 - // 字节0:消息类型(高4位) + 协议版本(低4位) - const byte0 = header[0]; - const msgType = (byte0 >> 4) & 0x0F; // 消息类型(0~15) - const version = byte0 & 0x0F; // 协议版本(0~15) - - // 校验消息类型 - if (!Object.values(MessageType).includes(msgType as MessageType)) { - throw new Error(`非法消息类型:${msgType}(0b${msgType.toString(2).padStart(4, '0')})`); - } - - // 校验版本 - if (version !== ProtocolConst.PROTOCOL_VERSION) { - throw new Error( - `协议版本不匹配:收到v${version}(0b${version.toString(2).padStart(4, '0')}),当前支持v${ProtocolConst.PROTOCOL_VERSION}(0b${ProtocolConst.PROTOCOL_VERSION.toString(2).padStart(4, '0')})` - ); - } - - // 字节1:序列化方式(高3位) + 压缩方式(中3位) + 保留位(低2位) - const byte1 = header[1]; - const serialization = (byte1 >> 5) & 0x07; // 高3位(1~8) - const compression = (byte1 >> 2) & 0x07; // 中3位(1~8) - // 保留位:(byte1 & 0x03),暂不处理 - - // 校验序列化方式 - if (!Object.values(SerializationType).includes(serialization as SerializationType)) { - throw new Error(`非法序列化方式:${serialization}(0b${serialization.toString(2).padStart(3, '0')})`); - } - - // 校验压缩方式 - if (!Object.values(CompressionType).includes(compression as CompressionType)) { - throw new Error(`非法压缩方式:${compression}(0b${compression.toString(2).padStart(3, '0')})`); - } - - // 字节2~3:消息顺序号(16位大端序) - const sequence = (header[2] << 8) | header[3]; // 0~65535 - - // 字节4~6:消息体长度(24位大端序),字节7:保留位(忽略) - const bodyLen = (header[4] << 16) | (header[5] << 8) | header[6]; - - // 校验包体长度(空包体时 bodyBuffer.length 应为0) - if (bodyBuffer.length !== bodyLen) { - throw new Error( - `包体长度不匹配:头部声明${bodyLen}字节,实际接收${bodyBuffer.length}字节` - ); - } - - // 4. 解压包体 - let decompressedBody : Uint8Array; - if (compression === CompressionType.NONE) { - decompressedBody = bodyBuffer; - } else if (compression === CompressionType.GZIP) { - // 若需启用GZIP,取消注释下方代码 - // try { - // decompressedBody = pako.ungzip(bodyBuffer); - // } catch (e) { - // throw new Error(`GZIP 解压失败:${(e as Error).message}`); - // } - throw new Error("GZIP 解压暂未启用,请导入pako库并取消对应代码注释"); - } else { - throw new Error(`不支持的压缩方式:${CompressionType[compression]}(值:${compression},0b${compression.toString(2).padStart(3, '0')})`); - } - - // 5. 反序列化包体(PING 消息空包体返回 null) - let body : UnpackedResult["body"]; - const textDecoder = new TextDecoder(); - - // 特殊处理:空包体(PING 消息常见)返回 null - if (decompressedBody.length === 0) { - body = null; - } else { - switch (serialization) { - case SerializationType.RAW: - body = decompressedBody; - break; - - case SerializationType.STRING: - try { - body = textDecoder.decode(decompressedBody); - } catch (e) { - throw new Error(`STRING 反序列化失败:UTF-8 解码错误`); - } - break; - - case SerializationType.JSON: - try { - const jsonStr = textDecoder.decode(decompressedBody); - body = JSON.parse(jsonStr); - } catch (e) { - if (e instanceof SyntaxError) { - throw new Error(`JSON 反序列化失败:格式错误(${(e as Error).message})`); - } else { - throw new Error(`JSON 反序列化失败:${(e as Error).message}`); - } - } - break; - - default: - throw new Error(`不支持的序列化方式:${SerializationType[serialization]}(值:${serialization},0b${serialization.toString(2).padStart(3, '0')})`); - } - } - - // 6. 返回解包结果 - return { - msgType: msgType as MessageType, - msgTypeName: MessageType[msgType] as keyof typeof MessageType, - serialization: serialization as SerializationType, - serializationName: SerializationType[serialization] as keyof typeof SerializationType, - compression: compression as CompressionType, - compressionName: CompressionType[compression] as keyof typeof CompressionType, - sequence: sequence, - body: body, - }; - } -} \ No newline at end of file From bbbfb3a3e470733b3d535aefd6237eb9d6b9f986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=B2=A9?= Date: Wed, 4 Mar 2026 14:36:35 +0800 Subject: [PATCH 4/6] =?UTF-8?q?JLBA202505130001=5F=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=90=89=E6=9E=97=E9=93=B6=E8=A1=8C=E6=96=B0=E5=BB=BAAI?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=9F=B9=E8=AE=AD=E7=B3=BB=E7=BB=9F=E7=9A=84?= =?UTF-8?q?=E9=9C=80=E6=B1=82=5F=E6=96=87=E4=BB=B6=E6=94=B9=E6=88=90?= =?UTF-8?q?=E5=B0=8F=E5=86=992?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/{ProtocolCodec.ts => asProtocolCodec.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/common/{ProtocolCodec.ts => asProtocolCodec.ts} (100%) diff --git a/src/common/ProtocolCodec.ts b/src/common/asProtocolCodec.ts similarity index 100% rename from src/common/ProtocolCodec.ts rename to src/common/asProtocolCodec.ts From cfd9c5f224a547d74484d806a7d378c45e280a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=B2=A9?= Date: Wed, 4 Mar 2026 14:37:08 +0800 Subject: [PATCH 5/6] =?UTF-8?q?JLBA202505130001=5F=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=90=89=E6=9E=97=E9=93=B6=E8=A1=8C=E6=96=B0=E5=BB=BAAI?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=9F=B9=E8=AE=AD=E7=B3=BB=E7=BB=9F=E7=9A=84?= =?UTF-8?q?=E9=9C=80=E6=B1=82=5F=E6=96=87=E4=BB=B6=E6=94=B9=E6=88=90?= =?UTF-8?q?=E5=B0=8F=E5=86=993?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/{asProtocolCodec.ts => protocolCodec.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/common/{asProtocolCodec.ts => protocolCodec.ts} (100%) diff --git a/src/common/asProtocolCodec.ts b/src/common/protocolCodec.ts similarity index 100% rename from src/common/asProtocolCodec.ts rename to src/common/protocolCodec.ts From 4981aed2dfb3ea662473b132db0dc6a2cf212a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E5=B2=A9?= Date: Wed, 4 Mar 2026 16:09:22 +0800 Subject: [PATCH 6/6] =?UTF-8?q?JLBA202505130001=5F=E5=85=B3=E4=BA=8E?= =?UTF-8?q?=E5=90=89=E6=9E=97=E9=93=B6=E8=A1=8C=E6=96=B0=E5=BB=BAAI?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E5=9F=B9=E8=AE=AD=E7=B3=BB=E7=BB=9F=E7=9A=84?= =?UTF-8?q?=E9=9C=80=E6=B1=82=5F=E5=BC=80=E5=8F=91=E5=BD=95=E9=9F=B3?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=EF=BC=8C=E5=9F=BA=E6=9C=AC=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 14 -- src/common/common.js | 8 +- src/components/touch-btn/style/popup.scss | 22 ++- src/components/touch-btn/touch-btn.types.ts | 11 +- src/components/touch-btn/touch-btn.vue | 161 +++++++--------- src/pages/index/dialog.vue | 200 +++++++++----------- src/pages/practice/index.vue | 14 +- 7 files changed, 190 insertions(+), 240 deletions(-) diff --git a/.env.development b/.env.development index 612cd003..93437482 100644 --- a/.env.development +++ b/.env.development @@ -2,25 +2,11 @@ ENV = 'development' # 'development' - - - #VITE_APP_BASE_API_Url = 'https://aits.jlbank.com.cn:7001' VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7001' -# VITE_APP_BASE_API_Url = 'https://aits.jlbank.com.cn:7001' - - -#VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7001' - -VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7002' -# VITE_APP_BASE_API_Url = 'http://192.168.247.200' - -# VITE_APP_BASE_API_Url = 'http://aitscdn.jlbank.com.cn:7001' - -# dev # DEV #VITE_APP_BASE_API_Url = 'http://25.18.122.65:7001' diff --git a/src/common/common.js b/src/common/common.js index 73107d3d..d9ec4948 100644 --- a/src/common/common.js +++ b/src/common/common.js @@ -335,17 +335,17 @@ export function setupKeyboardHeightListener(updateCallback) { const handleKeyboardHeightChange = (res) => { // 获取系统信息 const systemInfo = uni.getSystemInfoSync(); - console.log('systemInfo', systemInfo); + // console.log('systemInfo', systemInfo); // 计算安全区域底部高度 const safeAreaBottomHeight = systemInfo.screenHeight - systemInfo.safeArea.bottom; - console.log('计算安全区域底部高度', safeAreaBottomHeight); + // console.log('计算安全区域底部高度', safeAreaBottomHeight); let keyboardHeight = res.height; - console.log('软键盘高度', keyboardHeight); + // console.log('软键盘高度', keyboardHeight); // 仅在iOS端补偿安全区域高度 if (systemInfo.platform === 'ios') { keyboardHeight = Math.max(0, keyboardHeight); // 避免负数 } - console.log('keyboardHeight', keyboardHeight); + // console.log('keyboardHeight', keyboardHeight); // 通过回调函数更新外部的响应式变量 updateCallback(keyboardHeight); }; diff --git a/src/components/touch-btn/style/popup.scss b/src/components/touch-btn/style/popup.scss index 94b35bc4..1e65e767 100644 --- a/src/components/touch-btn/style/popup.scss +++ b/src/components/touch-btn/style/popup.scss @@ -51,7 +51,7 @@ position: absolute; width: 100%; bottom: 0; - &.voice_to_text { + &.voice_to_text,&.send_over { .btn-box-1 { animation: moveLeftOut 0.3s ease-out forwards; } @@ -139,7 +139,7 @@ bottom: 0; opacity: 0; visibility: hidden; // 初始不可见且不占交互位置 - &.voice_to_text { + &.voice_to_text,&.send_over { display: flex !important; visibility: visible; // 激活后可见 animation: fadeIn 0.4s ease-out 0.1s forwards; @@ -194,15 +194,19 @@ display: none; } } - &.init > .send-btn > .send-btn-loading { - display: block !important; + &.init > .send-btn { pointer-events: none; cursor: not-allowed; + .send-btn-loading { + display: block !important; + pointer-events: none; + cursor: not-allowed; + } } &.success > .send-btn > .send-btn-text { display: block !important; } - &.failed .send-btn { + &.failed .send-btn,&.networkError .send-btn { pointer-events: none; cursor: not-allowed; background-color: rgb(80, 80, 80); @@ -259,7 +263,7 @@ left: 78% !important; } - &.failed { + &.failed,&.networkError { width: 506rpx; height: 114rpx; min-height: 114rpx; @@ -274,7 +278,11 @@ // animation: fadeIn 0.4s ease-out 0.1s forwards; } .textarea { - // display: none; + visibility: hidden; // 初始不可见且不占交互位置 + } + } + &.init{ + .textarea { visibility: hidden; // 初始不可见且不占交互位置 } } diff --git a/src/components/touch-btn/touch-btn.types.ts b/src/components/touch-btn/touch-btn.types.ts index 71878349..4f1940b3 100644 --- a/src/components/touch-btn/touch-btn.types.ts +++ b/src/components/touch-btn/touch-btn.types.ts @@ -5,8 +5,9 @@ * - 'voice_recording' : 录音中状态(语音录制面板展开,正在录音) * - 'text_input' : 文字输入中状态(文字输入面板展开,用户正在输入) * - 'voice_to_text' : 语音转文字状态(独立的语音转文字页面/面板展开) + * - 'send_over' : 发送完成 */ -export type StatusType = 'default' | 'voice_recording' | 'text_input' | 'voice_to_text' | 'voice_send_ing'; +export type StatusType = 'default' | 'voice_recording' | 'text_input' | 'voice_to_text' | 'voice_send_ing' | 'send_over'; /** * 语音转文字的子状态(仅当主状态为 voice_to_text 时生效) @@ -16,7 +17,8 @@ export type VoiceToTextSubStatus = | 'init' // 初始化(刚进入语音转文字页面) // | 'loading' // 转换中(正在处理语音→文字) | 'success' // 转换完成(成功得到文字结果) - | 'failed'; // 转换失败(网络/识别错误等) + | 'failed' // 转换失败(网络/识别错误等) + | 'networkError'; // 转换失败(网络错误等) /** 错误码枚举 - 直接映射提示文本 */ @@ -33,6 +35,7 @@ export interface ShowErrorTipOptions { delayTime?: number; } +type ChangeBtnCallback = () => void; /** * 触摸按钮组件的自定义事件类型定义 @@ -48,9 +51,9 @@ export type TouchBtnEmits = { /** * 提交文字答案(用户确认识别结果/手动编辑后触发) * @param answerText - 最终提交的文字答案内容 - * @param isManualEdit - 是否手动编辑过识别结果(true=手动修改,false=纯语音识别) + * @param changeBtn - 按钮状态变更的回调函数 */ - submitAnswer: [answerText: string, isManualEdit: boolean]; + submitAnswer: [answerText: string, changeBtn: ChangeBtnCallback]; /** * 开始录音(用户点击录音按钮时触发) * @param recordParams - 录音初始化参数(可选,默认使用组件内置配置) diff --git a/src/components/touch-btn/touch-btn.vue b/src/components/touch-btn/touch-btn.vue index 22fae1b3..3d10a444 100644 --- a/src/components/touch-btn/touch-btn.vue +++ b/src/components/touch-btn/touch-btn.vue @@ -19,7 +19,7 @@ ref="inputRef" v-model="inputText" :maxlength="props.maxlength" - :adjustPosition="true" + :adjustPosition="false" placeholderStyle="color:#999999;font-size:26rpx" placeholder="请输入文字" :disabled="props.isDisabled" @@ -27,7 +27,7 @@ /> - + @@ -51,7 +51,8 @@ > - 未识别到文字 + 未识别到文字 + 网络识别超时 @@ -125,7 +126,6 @@ const status = ref('default'); const voice_to_text_status = ref('init'); const popupRef = ref(null); - const props = defineProps({ // mode控制功能模式 mode: { @@ -181,7 +181,8 @@ const dialogDynamicClasses = computed(() => { const classes = []; isOutStatus.value === '02' && classes.push('is-out'); - (isOutStatus.value === '03' || status.value === 'voice_to_text') && classes.push('is-translate'); + (isOutStatus.value === '03' || status.value === 'voice_to_text' || status.value === 'send_over') && + classes.push('is-translate'); // 仅语音模式,01松手后要和03操作一样 // isOutStatus.value === '01' && props.mode === 'text-only' && status.value == 'voice_to_text' && classes.push('is-translate'); voice_to_text_status.value !== '' && classes.push(voice_to_text_status.value); @@ -195,6 +196,7 @@ const touchPos = reactive({ x: 0, y: 0 }); // 是否录音中 const isRecording = ref(false); + let voiceTimer = null; // 兜底关闭的定时器全局唯一 // 键盘还是语音 const isInputVisible = ref(true); // true=显示录音按钮,false=显示输入框 const recordStartTime = ref(0); // 录音开始时间戳 @@ -262,33 +264,40 @@ // 返回结束消息 const controlBody = body as ControlBody; if (controlBody.type === ControlCode.SERVER_TO_FRONT_OVER) { - console.log('识别结束', controlBody); - // if(status.value === 'default') return; - // status.value = 'default'; // 恢复成默认状态 + console.log('识别结束', controlBody, voice_to_text_status.value); + // 如果不等于init说明已经超时,被超时函数处理完了,这里即使返回也不做处理 + if (status.value === 'voice_to_text' && voice_to_text_status.value !== 'init') return; + if (status.value === 'send_over') return; + if (voiceTimer) clearTimeout(voiceTimer); + // 设置识别结果 if (controlBody?.txt === '') { //说明没有识别到文字 voice_to_text_status.value = 'failed'; - emit('submitVoice', { ossKey:'', text:'', recordId: recordId.value }); - // todo } else { - // 识别成功 voice_to_text_status.value = 'success'; - const ossKey = controlBody?.ossKey; - let text = ''; - if (typeof controlBody?.txt === 'string') { - text = controlBody.txt.trim(); - } + } + // status.value = 'send_over'; // 设置成发送完成 + // 取值出来 + const ossKey = controlBody?.ossKey || ''; + let text = ''; + if (typeof controlBody?.txt === 'string') { + text = controlBody.txt.trim(); + } + const _isOutStatus = + props.mode === 'text-only' && isOutStatus.value === '01' ? '03' : isOutStatus.value; + // 只有模式为01的时候才发送出去 + if (_isOutStatus === '01') { emit('submitVoice', { ossKey, text, recordId: recordId.value }); } } } } catch (e) { - console.log('后端发来的信息有问题'); + console.log('后端发来的信息有问题', e); } }); }) .catch(() => { - stopRecord('networkError'); + // stopRecord('networkError'); console.log('网络出现问题'); }); pressTimer.value = setTimeout(() => { @@ -323,86 +332,28 @@ }); }, 120); }; - + // 结束录音 const closePopup = () => { popupRef.value.close(); }; - const stopRecord2 = async (errrStatus = '') => { - clearTimeout(pressTimer.value); - if (!isRecording.value) return; // 未处于录音状态 - if (isOutStatus.value === '03') { - status.value = 'voice_to_text'; //主状态设置为转文字 - voice_to_text_status.value = 'init'; - } else if (isOutStatus.value === '01') { - status.value = 'voice_send_ing'; //主状态设置为语音发送中 - } - if (['01', '03'].includes(isOutStatus.value)) { - // 向后端发送抬手动作 - ws.value && - ws.value.send({ - data: ProtocolCodec.pack(MessageType.CONTROL_CMD, { type: ControlCode.FRONT_TO_SERVER_OVER }) - }); - } - const recordDurationMs = Date.now() - recordStartTime.value; - if (isOutStatus.value !== '02') { - // 取消的时候,不校验最低时间 - // 核心:计算录音总时长(毫秒转秒,保留1位小数) - if (recordDurationMs < 600) { - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, 600 - recordDurationMs); - }); - } - } - isRecording.value = false; - if (isOutStatus.value === '02' || errrStatus === 'networkError') { - // 取消的时候自己主动关闭websocket,其他的时候需要等待后端的消息关闭 - ws.value && - ws.value.close({ - code: 1000, - reason: '挂断', - complete: () => { - console.log('挂断Ws'); - wsStatus.value = false; - } - }); - } - stopAudioRecord(); // 关掉录音 - if (isOutStatus.value !== '03' || errrStatus === 'networkError') { - // 转文字时候不关遮罩层,网络错误必须关遮罩层 - popupRef.value.close(); - // 关掉弹窗后的提示 - if (errrStatus === 'networkError') { - return showErrorTip(ErrorCode.NetworkError); - } else if (isOutStatus.value !== '02' && recordDurationMs <= 1200) { - // 取消的时候,不校验最低时间 - return showErrorTip(ErrorCode.ShortRecord); - } - } - if (isOutStatus.value === '01' && status.value == 'voice_send_ing') { - emit('sendLoading', { recordId: recordId.value }); - // 如果socket长时间没有返回,这里兜底一下 - setTimeout(() => { - if(status.value === 'default') return; - status.value = 'default'; // 恢复成默认状态 - emit('submitVoice', { ossKey:'', text:'', recordId: recordId.value }); - }, 5000) - } - - }; - const stopRecord = async (errrStatus = '') => { clearTimeout(pressTimer.value); if (!isRecording.value) return; // 未处于录音状态 - const _isOutStatus = (props.mode === 'text-only' && isOutStatus.value === '01')?"03":isOutStatus.value; + const _isOutStatus = props.mode === 'text-only' && isOutStatus.value === '01' ? '03' : isOutStatus.value; console.log('_isOutStatus', _isOutStatus); // 拖动到转文字按钮上抬手或者仅文字模式的发送也强行算他转文字 - if (_isOutStatus === '03' ) { + if (_isOutStatus === '03') { status.value = 'voice_to_text'; //主状态设置为转文字 voice_to_text_status.value = 'init'; + if (voiceTimer) clearTimeout(voiceTimer); + voiceTimer = setTimeout(() => { + voiceTimer = null; + if (voice_to_text_status.value !== 'init') return; + voice_to_text_status.value = 'networkError'; + emit('submitVoice', { ossKey: '', text: '', recordId: recordId.value }); + }, 5000); } else if (_isOutStatus === '01') { status.value = 'voice_send_ing'; //主状态设置为语音发送中 } @@ -439,7 +390,6 @@ }); } stopAudioRecord(); // 关掉录音 - if (_isOutStatus !== '03' || errrStatus === 'networkError') { // 转文字时候不关遮罩层,网络错误必须关遮罩层 popupRef.value.close(); @@ -454,13 +404,16 @@ if (_isOutStatus === '01' && status.value == 'voice_send_ing') { emit('sendLoading', { recordId: recordId.value }); // 如果socket长时间没有返回,这里兜底一下 - setTimeout(() => { - if(status.value === 'default') return; - status.value = 'default'; // 恢复成默认状态 - emit('submitVoice', { ossKey:'', text:'', recordId: recordId.value }); - }, 5000) + if (voiceTimer) clearTimeout(voiceTimer); + voiceTimer = setTimeout(() => { + voiceTimer = null; + if (props.mode === 'voice-only') { + if (status.value === 'send_over') return; + status.value = 'send_over'; // 恢复成默认状态 + emit('submitVoice', { ossKey: '', text: '', recordId: recordId.value }); + } + }, 5000); } - }; // 发送文本 @@ -483,16 +436,32 @@ isInputVisible.value = !isInputVisible.value; inputText.value = ''; }; + // 按钮右侧图标(三个功能) + const iconClick = () => { + if (props.isDisabled) return; + // 当是输入框且输入框的内容不为空时候,是发送文本,否则就是切换 + if (!isInputVisible.value && inputText.value !== '') { + console.log('send_msg'); + send_msg(); + } else { + isInputVisible.value = !isInputVisible.value; + } + inputText.value = ''; + }; // 禁用时点击给的提示 const disabled_click = () => { console.log('禁用时点击给的提示'); if (props.isDisabled) common.msg(props.disabledText); }; - + const send_msg = () => { - console.log('点击发送'); - + if (!isInputVisible.value) { + emit('submitAnswer', inputText.value, changeBtn); + } else { + emit('submitAnswer', textAreaText.value, changeBtn); + closePopup(); + } }; setupKeyboardHeightListener((height: number) => { diff --git a/src/pages/index/dialog.vue b/src/pages/index/dialog.vue index 03f220d3..3e8c9059 100644 --- a/src/pages/index/dialog.vue +++ b/src/pages/index/dialog.vue @@ -3,17 +3,31 @@ - + AI问答 内容由AI生成 - - - + + 历史 消息 @@ -65,18 +79,28 @@ --> - - + + {{ item }} - @@ -111,18 +135,25 @@ - - - + - + @@ -133,51 +164,20 @@