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..2a47c82c --- /dev/null +++ b/src/components/touch-btn/style/popup.scss @@ -0,0 +1,267 @@ +.overlay-box { + display: flex; + flex-direction: column; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + position: relative; +} +/* 1. 定义通用的向右移出动画(核心:添加时长,解决秒没) */ +@keyframes moveRightOut { + 0% { + right: 0; /* 初始位置 */ + } + 100% { + right: -400rpx; /* 目标位置:向右移出400rpx */ + } +} + +/* 2. 定义通用的向左移出动画(适配 btn-box-1) */ +@keyframes moveLeftOut { + 0% { + left: 0; /* 初始位置 */ + } + 100% { + left: -400rpx; /* 目标位置:向左移出400rpx */ + } +} +/* 2. 定义通用的向左移出动画(适配 btn-box-1) */ +@keyframes moveBottomOut { + 0% { + transform: translateY(0%); + } + 100% { + transform: translateY(100%); + } +} +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} +.page-1 { + position: absolute; + width: 100%; + bottom: 0; + &.voice_to_text{ + .btn-box-1 { + animation: moveLeftOut 0.3s ease-out forwards; + /* 清除冲突样式 */ + transform: none !important; + transition: none !important; + } + + .btn-box-2 { + animation: moveRightOut 0.3s ease-out forwards; + /* 清除冲突样式 */ + transform: none !important; + transition: none !important; + } + .circle-bg-box{ + animation: moveBottomOut 0.3s ease-out forwards; + } + } + // 两侧按钮 + .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; + } + } +} +.page-2{ + width: 100%; + overflow: hidden; + font-size: 28rpx; + text-align: center; + display: none; + z-index: 12000; + justify-content: space-around; + margin-bottom:234rpx;; + position: absolute; + bottom: 0; + opacity: 0; + visibility: hidden; // 初始不可见且不占交互位置 + &.voice_to_text { + display: flex !important; + visibility: visible; // 激活后可见 + animation: fadeIn 0.4s ease-out 0.1s forwards; + } + .transVoiceIng { + width: 154rpx; + height: 154rpx; + overflow: hidden; + position: relative; + z-index: 12010; + + &.cancel-btn { + background: url(@/static/images/course/cancel-btn.png) no-repeat; + background-size: cover; + } + + &.trans-btn { + background: url(@/static/images/course/trans-btn.png) no-repeat; + background-size: cover; + } + } + .send-btn { + position: relative; + width: 262rpx; + height: 154rpx; + line-height: 154rpx; + overflow: hidden; + background-color: #d8d8d8; + border-radius: 77rpx; + font-size: 33rpx; + z-index: 12010; + } +} +.talk-dialog-box{ + position: absolute; + bottom: 520rpx ; + width: 100%; + display: flex; + justify-content: center; +} +.talk-dialog-div { + width: 506rpx; + min-height: 234rpx; + background-color: #06f; + border-radius: 32rpx; + transition: width 0.3s ease; + // 滑动到离开变红 + &.is-out { + background-color: #ff3e49; + + &::after { + background-color: #ff3e49; + } + } + // 滑动到转文字变宽 + &.is-translate { + width: 706rpx; + .gif-box { + display: none; + } + } + &::after { + content: ''; + width: 44rpx; + height: 44rpx; + clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); + background-color: #06f; + overflow: hidden; + position: absolute; + bottom: -22rpx; + left: 50%; + margin-left: -22rpx; + transition: left 0.3s ease-out; // 时长0.3秒,ease-out缓动(结束时变慢,更自然) + } + // 控制下边箭头指向发送 + &.init::after { + left: 78% !important; + } + &.failed { + width: 506rpx; + height: 114rpx; + min-height: 114rpx; + background-color: #ff3e49; + &::after { + background-color: #ff3e49; + } + // 激活错误提示框 + .error-tip-div{ + display: flex !important; + visibility: visible; // 激活后可见 + // animation: fadeIn 0.4s ease-out 0.1s forwards; + } + } +} +.talk-dialog { + width: 100%; + height: 100%; + overflow: hidden; + position: relative; + .text-div { + overflow-y: scroll; + max-height: 434rpx; + padding: 20rpx; + font-size: 32rpx; + color: #fff; + line-height: 40rpx; + } + .gif-box { + position: absolute; + width: 200rpx; + height: 80rpx; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + .error-tip-div{ + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + display: none; + visibility: hidden; // 初始不可见且不占交互位置 + + display: flex; + align-items: center; + color: #ffffff; + font-size: 24rpx; + font-weight: 400; + .img{ + margin-right: 10rpx; + width: 26rpx; + height: 26rpx; + } + } + + + + + + +} diff --git a/src/components/touch-btn/touch-btn.images.ts b/src/components/touch-btn/touch-btn.images.ts index 500a7104..83c8e11e 100644 --- a/src/components/touch-btn/touch-btn.images.ts +++ b/src/components/touch-btn/touch-btn.images.ts @@ -1,6 +1,31 @@ // 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 iconMicSvg = () : string => { + const svgXml = ` + + `; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgXml)}`; +}; +export const iconSendSvg = () : string => { + const svgXml = ` + + `; + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgXml)}`; +}; +export const iconRecognizeErrorSvg = () : 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..94387d3c 100644 --- a/src/components/touch-btn/touch-btn.ts +++ b/src/components/touch-btn/touch-btn.ts @@ -1,9 +1,12 @@ +import { ErrorCode, ShowErrorTipOptions } from './touch-btn.types'; 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 +15,82 @@ 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); + }); +}; + +/** + * 错误提示统一处理函数(极简版) + * @param errorCode 错误码(枚举类型,直接对应提示文本) + * @param options 可选参数:延迟显示时间 + * @returns 固定返回false(保持原有返回值类型) + */ +export function showErrorTip( + errorCode: ErrorCode, + options: ShowErrorTipOptions = {} +): boolean { + // 解构可选参数,设置默认延迟时间为200ms + const { delayTime = 200 } = options; + + // 延迟显示对应的错误提示 + setTimeout(() => { + uni.showToast({ + title: errorCode, // 直接使用枚举值作为提示文本 + icon: 'none' + }); + }, delayTime); // 使用传入的延迟时间(或默认200ms) + + return false; +} \ 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..a32303e4 100644 --- a/src/components/touch-btn/touch-btn.types.ts +++ b/src/components/touch-btn/touch-btn.types.ts @@ -1 +1,34 @@ -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'; // 转换失败(网络/识别错误等) + + +/** 错误码枚举 - 直接映射提示文本 */ +export enum ErrorCode { + // 网络错误提示 + NetworkError = '网络连接失败,请稍后再试!', + // 录音时长过短提示 + ShortRecord = '说话时间太短,没有听清!' +} + +/** 错误提示函数的可选参数类型 */ +export interface ShowErrorTipOptions { + // 提示框延迟显示时间(毫秒),默认200ms + delayTime?: number; +} \ 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..316c6797 100644 --- a/src/components/touch-btn/touch-btn.vue +++ b/src/components/touch-btn/touch-btn.vue @@ -1,116 +1,99 @@ + \ 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);