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 @@
-
-
-
-
- {{buttonText}}
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+ {{buttonText}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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);