diff --git a/.env.development b/.env.development index 5bb8ccd2..6e7704c3 100644 --- a/.env.development +++ b/.env.development @@ -7,7 +7,7 @@ ENV = 'development' #VITE_APP_BASE_API_Url = 'https://aits.jlbank.com.cn:7001' -#VITE_APP_BASE_API_Url = 'https://aitstest.jlbank.com.cn:7002' +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' @@ -19,7 +19,7 @@ ENV = 'development' #VITE_APP_BASE_API_Url = 'http://25.18.122.65:7001' # sit -VITE_APP_BASE_API_Url = 'http://25.18.122.91:9786' +#VITE_APP_BASE_API_Url = 'http://25.18.122.91:9786' # UAT # VITE_APP_BASE_API_Url = 'http://25.18.122.78:9786' diff --git a/shims-uni.d.ts b/shims-uni.d.ts index ed4adcfc..55290988 100644 --- a/shims-uni.d.ts +++ b/shims-uni.d.ts @@ -2,9 +2,9 @@ import 'vue' declare module '@vue/runtime-core' { - type Hooks = App.AppInstance & Page.PageInstance; + type Hooks = App.AppInstance & Page.PageInstance; - interface ComponentCustomOptions extends Hooks { + interface ComponentCustomOptions extends Hooks { - } -} + } +} \ No newline at end of file diff --git a/src/common/ProtocolCodec.ts b/src/common/ProtocolCodec.ts new file mode 100644 index 00000000..2129c88e --- /dev/null +++ b/src/common/ProtocolCodec.ts @@ -0,0 +1,405 @@ +// 导入 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提示命令已经全部返回 + FRNOT_TO_SERVER_OVER = 0b0111, // 语音按钮已经抬起 + SERVER_TO_FRNOT_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 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; // 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 diff --git a/src/common/permission.js b/src/common/permission.js index 2d62c0be..aae86d4d 100644 --- a/src/common/permission.js +++ b/src/common/permission.js @@ -69,6 +69,7 @@ function judgeIosPermissionLocation() { // 判断麦克风权限是否开启 function judgeIosPermissionRecord() { + return new Promise(async (resolve, reject) => { const authSetting = uni.getAppAuthorizeSetting(); const state = authSetting.microphoneAuthorized; @@ -257,6 +258,9 @@ function requestAndroidPermission(permissionID) { // 使用一个方法,根据参数判断权限 function judgeIosPermission(permissionID) { + // #ifdef H5 + return; + // #endif if (permissionID == "location") { return judgeIosPermissionLocation() } else if (permissionID == "camera") { diff --git a/src/components/touch-btn/assets/cancel-active.png b/src/components/touch-btn/assets/cancel-active.png new file mode 100644 index 00000000..7c762380 Binary files /dev/null and b/src/components/touch-btn/assets/cancel-active.png differ diff --git a/src/components/touch-btn/assets/cancel.png b/src/components/touch-btn/assets/cancel.png new file mode 100644 index 00000000..4edd76e6 Binary files /dev/null and b/src/components/touch-btn/assets/cancel.png differ diff --git a/src/components/touch-btn/assets/translate-active.png b/src/components/touch-btn/assets/translate-active.png new file mode 100644 index 00000000..ce1a14a8 Binary files /dev/null and b/src/components/touch-btn/assets/translate-active.png differ diff --git a/src/components/touch-btn/assets/translate.png b/src/components/touch-btn/assets/translate.png new file mode 100644 index 00000000..1e8100ae Binary files /dev/null and b/src/components/touch-btn/assets/translate.png differ diff --git a/src/components/touch-btn/assets/voice-white.gif b/src/components/touch-btn/assets/voice-white.gif new file mode 100644 index 00000000..6010ae1b Binary files /dev/null and b/src/components/touch-btn/assets/voice-white.gif differ diff --git a/src/components/touch-btn/style/index.scss b/src/components/touch-btn/style/index.scss new file mode 100644 index 00000000..db0667be --- /dev/null +++ b/src/components/touch-btn/style/index.scss @@ -0,0 +1,85 @@ +.talk-btns { + position: flex; + left: 0; + bottom: 0; + overflow: hidden; + width: calc(100vw - 60rpx); + margin: 10rpx 20rpx; + background-color: #fff; + box-shadow: 0 4rpx 14rpx rgba(100, 100, 100, 0.3); + padding: 0 10rpx; + + display: flex; + align-items: center; + border: 2rpx solid rgba(0, 0, 0, 0); + border-radius: 8rpx; + box-sizing: border-box; + &.inputMode { + border: 2rpx solid #dedede; + } + + &.action { + background-color: #dadada; + } + + + // 左侧输入框和语音按钮的父盒子 + .touch-btn { + flex: 1; + // 录音按钮 + .voice-btn { + border: none; + height: 90rpx; + .voice-text { + font-size: 30rpx; + } + } + // 输入盒子 + .input-box { + height: 90rpx; + } + } + // 右侧切换图标的盒子 + .talk-right-icon-div { + margin: 10rpx 10rpx 0 0; + .talk-right-icon { + width: 50rpx; + height: 50rpx; + } + } + // .touch-btn { + // height: 92rpx; + // box-shadow: 0 8rpx 20rpx 0 rgba(0, 0, 0, 0.06); + // text-align: center; + // line-height: 92rpx; + // color: #000; + // font-weight: 400; + // position: relative; + + // .input-box { + // width: 100%; + // height: 92rpx; + // line-height: 92rpx; + // text-align: left; + // box-sizing: border-box; + // padding: 0 40rpx 0 40rpx; + // } + // } + + // .talk-btn-box { + // position: absolute; + // height: 100%; + // width: 120rpx; + // right: 0; + // top: 0; + + // .talk-btn-icon { + // width: 40rpx; + // height: 40rpx; + // position: absolute; + // right: 60rpx; + // top: 50%; + // margin-top: -20rpx; + // } + // } +} diff --git a/src/components/touch-btn/style/popup.scss b/src/components/touch-btn/style/popup.scss new file mode 100644 index 00000000..370812d2 --- /dev/null +++ b/src/components/touch-btn/style/popup.scss @@ -0,0 +1,96 @@ +.overlay-box { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); +} +/* 1. 定义通用的向右移出动画(核心:添加时长,解决秒没) */ +@keyframes moveRightOut { + 0% { + right: 0; /* 初始位置 */ + } + 100% { + right: -400rpx; /* 目标位置:向右移出400rpx */ + } +} + +/* 2. 定义通用的向左移出动画(适配 btn-box-1) */ +@keyframes moveLeftOut { + 0% { + left: 0; /* 初始位置 */ + } + 100% { + left: -400rpx; /* 目标位置:向左移出400rpx */ + } +} + +.page-1 { + background-color: red; + &.init { + .btn-box-1 { + position: absolute; /* 定位是动画生效的前提 */ + left: 0; /* 初始位置 */ + /* 核心动画配置:时长+曲线+保留最终状态 */ + animation: moveLeftOut 0.5s ease-out forwards; + /* 清除冲突样式 */ + transform: none !important; + transition: none !important; + } + + .btn-box-2 { + position: absolute; /* 定位是动画生效的前提 */ + right: 0; /* 初始位置 */ + /* 核心动画配置:时长+曲线+保留最终状态 */ + animation: moveRightOut 0.5s ease-out forwards; + /* 清除冲突样式 */ + transform: none !important; + transition: none !important; + } + } + // 两侧按钮 + .btns-box { + height: 240rpx; + width: 100%; + font-size: 28rpx; + display: flex; + z-index: 1200; + justify-content: space-around; + position: relative; + + .btn-box { + position: absolute; + height: 164rpx; + transform: scale(1.2); + bottom: -10rpx; + } + + .btn-box-1 { + transition: left 0.3s ease-out; + left: -40rpx; + } + + .btn-box-2 { + transition: right 0.3s ease-out; + right: -40rpx; + } + } + // 底部半圆 + .circle-bg-box { + height: 234rpx; + background: url(@/static/images/course/talking.png) no-repeat; + background-size: cover; + position: relative; + .tips-box { + height: 40rpx; + line-height: 40rpx; + color: #c0c0c0; + text-align: center; + margin: 36rpx 0 12rpx 0; + font-weight: 600; + font-size: 34rpx; + color: #000000; + line-height: 46px; + } + } +} diff --git a/src/components/touch-btn/touch-btn.images.ts b/src/components/touch-btn/touch-btn.images.ts index 500a7104..582490e8 100644 --- a/src/components/touch-btn/touch-btn.images.ts +++ b/src/components/touch-btn/touch-btn.images.ts @@ -1,6 +1,25 @@ // components/touch-btn/touch-btn.images.ts // 统一导出所有需要的图片资源 -export { default as cancelBtn } from '@/static/images/course/cancel.png'; -export { default as cancelActiveBtn } from '@/static/images/course/cancel-active.png'; -export { default as translateBtn } from '@/static/images/course/translate.png'; -export { default as translateActiveBtn } from '@/static/images/course/translate-active.png'; \ No newline at end of file +export { default as cancelBtn } from './assets/cancel.png'; +export { default as cancelActiveBtn } from './assets/cancel-active.png'; +export { default as translateBtn } from './assets/translate.png'; +export { default as translateActiveBtn } from './assets/translate-active.png'; +export { default as voiceWhiteBtn } from './assets/voice-white.gif'; +export const icKeyboardSvg = () : string => { + const svgXml = ` + + `; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgXml)}`; +}; +export const icMicSvg = () : string => { + const svgXml = ` + + `; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgXml)}`; +}; +export const icSendSvg = () : string => { + const svgXml = ` + + `; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgXml)}`; +}; diff --git a/src/components/touch-btn/touch-btn.ts b/src/components/touch-btn/touch-btn.ts index b5802764..f5a04e0d 100644 --- a/src/components/touch-btn/touch-btn.ts +++ b/src/components/touch-btn/touch-btn.ts @@ -1,9 +1,11 @@ import { get_base_url } from '@/api/request.js'; +import { getToken } from '@/common/common.js'; // const url = '/trapractice/voiceCallSocket/voiceCall' // const url = '/traapp/voiceTransSocket/open'; const url = '/traapp/voiceAiModelSocket/open'; const _baseUrl = get_base_url(url).split('http').join('ws'); -export const wsUrl = _baseUrl + url; +// const _baseUrl = ''; +export const wsUrl = _baseUrl + url + '?summary=' + getToken(); const windowInfo = uni.getWindowInfo(); // 安全区域信息 const safeArea = windowInfo.safeArea; @@ -12,4 +14,69 @@ const btnHeight = (windowInfo.screenWidth / 750) * 234; // 按钮的顶部边界 export const bottomAreaTopBoundary = safeArea.bottom - btnHeight; // 屏幕横向分割线 -export const horizontalSplitX = safeArea.right / 2; \ No newline at end of file +export const horizontalSplitX = safeArea.right / 2; + + +// 假设你在 Vue3 环境中,ws、wsStatus 是 ref 响应式变量 +import { ref } from 'vue'; + +const ws = ref(null); +const wsStatus = ref(false); + +/** + * 封装WebSocket连接为Promise + * @param {string} wsUrl - WebSocket连接地址 + * @returns {Promise} 成功时返回WS实例,失败时reject错误信息 + */ +export const initWebSocket = (wsUrl: string): Promise => { + return new Promise((resolve, reject) => { + // 1. 创建WS连接 + const ws = uni.connectSocket({ + url: wsUrl, + fail: (err) => { + console.error('WS连接创建失败:', err); + // 连接创建失败直接reject + reject(new Error(`WS连接创建失败:${err.errMsg || err.message}`)); + }, + success: () => { + console.log('WS连接创建请求已发送'); + } + }); + + // 2. 监听连接成功(核心:连接成功时resolve,返回WS实例) + const onOpenHandler = (res) => { + console.log('ws已连接', res); + // resolve返回WS实例 + resolve(ws); + }; + + // 4. 监听连接错误(出错时reject) + const onErrorHandler = (err) => { + console.log('WS连接错误', err); + // reject返回错误信息 + reject(new Error(`WS连接错误:${err.errMsg || err.message}`)); + }; + + // 5. 监听连接关闭(如果在open前关闭,也reject) + const onCloseHandler = (err) => { + console.log('WS连接已关闭', err); + // 连接未成功就关闭,执行reject + reject(new Error(`WS连接已关闭:${err.errMsg || err.message}`)); + }; + // 绑定监听事件 + ws.onOpen(onOpenHandler); + ws.onError(onErrorHandler); + ws.onClose(onCloseHandler); + }); +}; + + + +// 方式2:Promise.then/catch 调用 +// initWebSocket('wss://your-real-ws-url.com') +// .then((socketTask) => { +// console.log('WS连接成功', socketTask); +// }) +// .catch((error) => { +// console.error('WS连接失败', error); +// }); \ No newline at end of file diff --git a/src/components/touch-btn/touch-btn.types.ts b/src/components/touch-btn/touch-btn.types.ts index e88d046c..1df7c659 100644 --- a/src/components/touch-btn/touch-btn.types.ts +++ b/src/components/touch-btn/touch-btn.types.ts @@ -1 +1,18 @@ -export type StatusType = 'voice' | 'input' | 'text'; \ No newline at end of file +/** + * 组件的核心互斥状态(同一时间仅能存在一种状态) + * 所有状态均为语义化字符串,无空值,避免歧义 + * - 'default' : 默认/初始状态(未弹出任何功能面板,组件处于初始态) + * - 'voice_recording' : 录音中状态(语音录制面板展开,正在录音) + * - 'text_input' : 文字输入中状态(文字输入面板展开,用户正在输入) + * - 'voice_to_text' : 语音转文字状态(独立的语音转文字页面/面板展开) + */ +export type StatusType = 'default' | 'voice_recording' | 'text_input' | 'voice_to_text'; + +/** + * 语音转文字的子状态(仅当主状态为 voice_to_text 时生效) + */ +export type VoiceToTextSubStatus = + | 'init' // 初始化(刚进入语音转文字页面) + | 'loading' // 转换中(正在处理语音→文字) + | 'success' // 转换完成(成功得到文字结果) + | 'failed'; // 转换失败(网络/识别错误等) \ No newline at end of file diff --git a/src/components/touch-btn/touch-btn.vue b/src/components/touch-btn/touch-btn.vue index f082de22..e256e8e7 100644 --- a/src/components/touch-btn/touch-btn.vue +++ b/src/components/touch-btn/touch-btn.vue @@ -1,116 +1,88 @@ + \ No newline at end of file diff --git a/src/components/touch-btn2/touch-btn.images.ts b/src/components/touch-btn2/touch-btn.images.ts new file mode 100644 index 00000000..500a7104 --- /dev/null +++ b/src/components/touch-btn2/touch-btn.images.ts @@ -0,0 +1,6 @@ +// components/touch-btn/touch-btn.images.ts +// 统一导出所有需要的图片资源 +export { default as cancelBtn } from '@/static/images/course/cancel.png'; +export { default as cancelActiveBtn } from '@/static/images/course/cancel-active.png'; +export { default as translateBtn } from '@/static/images/course/translate.png'; +export { default as translateActiveBtn } from '@/static/images/course/translate-active.png'; \ No newline at end of file diff --git a/src/components/touch-btn2/touch-btn.ts b/src/components/touch-btn2/touch-btn.ts new file mode 100644 index 00000000..b5802764 --- /dev/null +++ b/src/components/touch-btn2/touch-btn.ts @@ -0,0 +1,15 @@ +import { get_base_url } from '@/api/request.js'; +// const url = '/trapractice/voiceCallSocket/voiceCall' +// const url = '/traapp/voiceTransSocket/open'; +const url = '/traapp/voiceAiModelSocket/open'; +const _baseUrl = get_base_url(url).split('http').join('ws'); +export const wsUrl = _baseUrl + url; +const windowInfo = uni.getWindowInfo(); +// 安全区域信息 +const safeArea = windowInfo.safeArea; +// 按钮高度 +const btnHeight = (windowInfo.screenWidth / 750) * 234; +// 按钮的顶部边界 +export const bottomAreaTopBoundary = safeArea.bottom - btnHeight; +// 屏幕横向分割线 +export const horizontalSplitX = safeArea.right / 2; \ No newline at end of file diff --git a/src/components/touch-btn2/touch-btn.types.ts b/src/components/touch-btn2/touch-btn.types.ts new file mode 100644 index 00000000..e88d046c --- /dev/null +++ b/src/components/touch-btn2/touch-btn.types.ts @@ -0,0 +1 @@ +export type StatusType = 'voice' | 'input' | 'text'; \ No newline at end of file diff --git a/src/components/touch-btn2/touch-btn.vue b/src/components/touch-btn2/touch-btn.vue new file mode 100644 index 00000000..f082de22 --- /dev/null +++ b/src/components/touch-btn2/touch-btn.vue @@ -0,0 +1,541 @@ + + + + + diff --git a/src/pages/index/components/class_scroll.vue b/src/pages/index/components/class_scroll.vue index 74f53a60..ad70d359 100644 --- a/src/pages/index/components/class_scroll.vue +++ b/src/pages/index/components/class_scroll.vue @@ -22,7 +22,7 @@ - {{ item.name}} + {{ item.name || ''}} diff --git a/src/pages/index/dialog.vue b/src/pages/index/dialog.vue index 699f2722..dd226f8b 100644 --- a/src/pages/index/dialog.vue +++ b/src/pages/index/dialog.vue @@ -111,18 +111,18 @@ - - + - + @@ -146,7 +146,7 @@ import { setupKeyboardHeightListener } from '@/common/common'; - import TouchBtn from '@/pages/course/components/touchBtn.vue'; + // import TouchBtn from '@/pages/course/components/touchBtn.vue'; import TalkingItem from './components/talking-item.vue'; import PreviewVue from './components/preview.vue'; import { diff --git a/src/pages/login/login.vue b/src/pages/login/login.vue index 10e9cae3..30722ba6 100644 --- a/src/pages/login/login.vue +++ b/src/pages/login/login.vue @@ -83,6 +83,12 @@ const show_password = ref(false); const account = ref(''); const password = ref(''); + const ENV = import.meta.env; + console.log('ENV.MODE', ENV.MODE); + if (['development'].includes(ENV.MODE)) { + account.value = 'admin'; + password.value = '123456'; + } const read_text_state = ref>([]); const imeHeight = ref('0px'); const back_state = ref(true);