diff --git a/package.json b/package.json
index 217b2a1a..040ad8a2 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"@dcloudio/vite-plugin-uni": "3.0.0-4060620250520001",
"@vue/runtime-core": "^3.4.21",
"sass": "1.77.0",
+ "typescript": "^5.9.3",
"vite": "5.2.8"
}
}
diff --git a/src/common/ProtocolCodec.ts b/src/common/ProtocolCodec.ts
index 2129c88e..dd6e501d 100644
--- a/src/common/ProtocolCodec.ts
+++ b/src/common/ProtocolCodec.ts
@@ -47,8 +47,8 @@ export enum ControlCode {
AI_ANSWER_OVER = 0b0100, // 本轮AI回答的文本内容已经全部返回
AI_CLUE = 0b0101, // 需要AI提示命令(发后端)
AI_CLUE_OVER = 0b0110, // AI提示命令已经全部返回
- FRNOT_TO_SERVER_OVER = 0b0111, // 语音按钮已经抬起
- SERVER_TO_FRNOT_OVER = 0b1000, // 后端已经返回本次全部内容
+ FRONT_TO_SERVER_OVER = 0b0111, // 语音按钮已经抬起
+ SERVER_TO_FRONT_OVER = 0b1000, // 后端已经返回本次全部内容
}
@@ -97,6 +97,11 @@ export enum ControlCommand {
FINISH_PLAY = 0b0010, // 一句话播放结束
}
+export interface ControlBody {
+ type: ControlCode; // type 是必选属性,关联你的 ControlCode 枚举
+ [key: string]: any; // 索引签名:兼容后端返回的任意动态属性(txt/ossKey 等)
+}
+
/**
* 解包返回结果接口
*/
@@ -108,7 +113,7 @@ export interface UnpackedResult {
compression : CompressionType;
compressionName : keyof typeof CompressionType;
sequence : number; // 消息顺序号(0~65535,默认0)
- body : Uint8Array | string | object | unknown[] | null; // PING 消息可能返回 null
+ body : Uint8Array | string | object | unknown[] | null | ControlBody; // PING 消息可能返回 null
}
/**
diff --git a/src/common/common.js b/src/common/common.js
index 45127627..73107d3d 100644
--- a/src/common/common.js
+++ b/src/common/common.js
@@ -83,12 +83,10 @@ const hideLoading = () => uni.hideLoading()
// 根据姓名的长度生成对应的带星号格式
export const formatName = (name) => {
-
// 处理空值或非字符串情况
if (!name || typeof name !== 'string') {
return '';
}
-
const familyName = name.charAt(0); // 取姓氏(第一个字符)
const givenNameLength = name.length - 1; // 名字部分的长度
diff --git a/src/components/touch-btn/assets/close-active.png b/src/components/touch-btn/assets/close-active.png
new file mode 100644
index 00000000..f139f096
Binary files /dev/null and b/src/components/touch-btn/assets/close-active.png differ
diff --git a/src/components/touch-btn/assets/close.png b/src/components/touch-btn/assets/close.png
new file mode 100644
index 00000000..080473f6
Binary files /dev/null and b/src/components/touch-btn/assets/close.png differ
diff --git a/src/components/touch-btn/style/index.scss b/src/components/touch-btn/style/index.scss
index db0667be..f2d405dc 100644
--- a/src/components/touch-btn/style/index.scss
+++ b/src/components/touch-btn/style/index.scss
@@ -1,4 +1,4 @@
-.talk-btns {
+.talk-btns-components {
position: flex;
left: 0;
bottom: 0;
diff --git a/src/components/touch-btn/style/popup.scss b/src/components/touch-btn/style/popup.scss
index 2a47c82c..e21164c4 100644
--- a/src/components/touch-btn/style/popup.scss
+++ b/src/components/touch-btn/style/popup.scss
@@ -3,66 +3,74 @@
flex-direction: column;
width: 100vw;
height: 100vh;
- background-color: rgba(0, 0, 0, 0.8);
+ background-color: rgba(0, 0, 0, 0.85);
position: relative;
}
/* 1. 定义通用的向右移出动画(核心:添加时长,解决秒没) */
@keyframes moveRightOut {
- 0% {
- right: 0; /* 初始位置 */
- }
- 100% {
- right: -400rpx; /* 目标位置:向右移出400rpx */
- }
+ 0% {
+ right: 0; /* 初始位置 */
+ }
+ 100% {
+ right: -400rpx; /* 目标位置:向右移出400rpx */
+ }
}
/* 2. 定义通用的向左移出动画(适配 btn-box-1) */
@keyframes moveLeftOut {
- 0% {
- left: 0; /* 初始位置 */
- }
- 100% {
- left: -400rpx; /* 目标位置:向左移出400rpx */
- }
+ 0% {
+ left: 0; /* 初始位置 */
+ }
+ 100% {
+ left: -400rpx; /* 目标位置:向左移出400rpx */
+ }
}
/* 2. 定义通用的向左移出动画(适配 btn-box-1) */
@keyframes moveBottomOut {
- 0% {
- transform: translateY(0%);
- }
- 100% {
- transform: translateY(100%);
- }
+ 0% {
+ transform: translateY(0%);
+ }
+ 100% {
+ transform: translateY(130%);
+ }
}
@keyframes fadeIn {
- 0% {
- opacity: 0;
- }
- 100% {
- opacity: 1;
- }
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+.page-box {
+ position: relative;
+ height: 500rpx;
+ transition: margin-bottom 0.2s ease-out;
}
.page-1 {
position: absolute;
width: 100%;
bottom: 0;
- &.voice_to_text{
+ &.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;
- }
+ animation: moveLeftOut 0.3s ease-out forwards;
+ }
+ .btn-box-2 {
+ animation: moveRightOut 0.3s ease-out forwards;
+ }
+ .circle-bg-box {
+ animation: moveBottomOut 0.3s ease-out forwards;
+ }
+ }
+ // 仅语音
+ &.voice-only {
+ .btn-box-1,
+ .btn-box-2 {
+ display: none;
+ }
+ .btn-box-3 {
+ display: block !important;
+ }
}
// 两侧按钮
.btns-box {
@@ -73,7 +81,6 @@
z-index: 1200;
justify-content: space-around;
position: relative;
-
.btn-box {
position: absolute;
height: 164rpx;
@@ -90,6 +97,13 @@
transition: right 0.3s ease-out;
right: -40rpx;
}
+ .btn-box-3 {
+ position: absolute;
+ height: 124rpx;
+ transform: scale(1.2);
+ bottom: 30rpx;
+ display: none;
+ }
}
// 底部半圆
.circle-bg-box {
@@ -110,40 +124,52 @@
}
}
}
-.page-2{
+.page-2 {
width: 100%;
overflow: hidden;
+ padding-bottom: 50rpx;
font-size: 28rpx;
text-align: center;
display: none;
- z-index: 12000;
+ z-index: 1200;
justify-content: space-around;
- margin-bottom:234rpx;;
+ margin-bottom: 214rpx;
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;
+ display: flex !important;
+ visibility: visible; // 激活后可见
+ animation: fadeIn 0.4s ease-out 0.1s forwards;
}
.transVoiceIng {
width: 154rpx;
height: 154rpx;
- overflow: hidden;
+ overflow: visible;
position: relative;
- z-index: 12010;
-
+ z-index: 1200;
&.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;
}
+ .btn-text {
+ position: absolute;
+ top: calc(100% + 15rpx); /* 按钮底部+10rpx间距,避免贴边 */
+ left: 50%;
+ transform: translateX(-50%);
+ /* 2. 强制显示 + 最高层级,防止被遮挡 */
+ display: block !important;
+ z-index: 10000;
+ color: rgb(163, 163, 163);
+ font-size: 28rpx;
+ white-space: nowrap;
+ }
}
.send-btn {
position: relative;
@@ -154,12 +180,36 @@
background-color: #d8d8d8;
border-radius: 77rpx;
font-size: 33rpx;
- z-index: 12010;
+ z-index: 1200;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ .send-btn-loading,
+ .send-btn-text {
+ display: none;
+ }
+ }
+ &.init > .send-btn > .send-btn-loading {
+ display: block !important;
+ pointer-events: none;
+ cursor: not-allowed;
+ }
+ &.success > .send-btn > .send-btn-text {
+ display: block !important;
+ }
+ &.failed .send-btn {
+ pointer-events: none;
+ cursor: not-allowed;
+ background-color: rgb(80, 80, 80);
+ color: rgb(106, 106, 106);
+ .send-btn-text {
+ display: block !important;
+ }
}
}
-.talk-dialog-box{
- position: absolute;
- bottom: 520rpx ;
+.talk-dialog-box {
+ position: relative;
+ // bottom: 560rpx;
width: 100%;
display: flex;
justify-content: center;
@@ -173,7 +223,7 @@
// 滑动到离开变红
&.is-out {
background-color: #ff3e49;
-
+
&::after {
background-color: #ff3e49;
}
@@ -199,9 +249,11 @@
transition: left 0.3s ease-out; // 时长0.3秒,ease-out缓动(结束时变慢,更自然)
}
// 控制下边箭头指向发送
- &.init::after {
+ &.init::after,
+ &.success::after {
left: 78% !important;
}
+
&.failed {
width: 506rpx;
height: 114rpx;
@@ -211,11 +263,15 @@
background-color: #ff3e49;
}
// 激活错误提示框
- .error-tip-div{
+ .error-tip-div {
display: flex !important;
visibility: visible; // 激活后可见
// animation: fadeIn 0.4s ease-out 0.1s forwards;
}
+ .textarea {
+ // display: none;
+ visibility: hidden; // 初始不可见且不占交互位置
+ }
}
}
.talk-dialog {
@@ -239,29 +295,22 @@
top: 50%;
transform: translate(-50%, -50%);
}
- .error-tip-div{
+ .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{
+ .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 83c8e11e..c6f901a8 100644
--- a/src/components/touch-btn/touch-btn.images.ts
+++ b/src/components/touch-btn/touch-btn.images.ts
@@ -4,7 +4,11 @@ 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 closeBtn } from './assets/close.png';
+export { default as closeActiveBtn } from './assets/close-active.png';
+
export { default as voiceWhiteBtn } from './assets/voice-white.gif';
+
export const icKeyboardSvg = () : string => {
const svgXml = `
diff --git a/src/components/touch-btn/touch-btn.ts b/src/components/touch-btn/touch-btn.ts
index 94387d3c..f778640e 100644
--- a/src/components/touch-btn/touch-btn.ts
+++ b/src/components/touch-btn/touch-btn.ts
@@ -1,11 +1,13 @@
import { ErrorCode, ShowErrorTipOptions } from './touch-btn.types';
import { get_base_url } from '@/api/request.js';
-import { getToken } from '@/common/common.js';
+import { getToken, getPhoneEnvBool } from '@/common/common.js';
+const IsAndEnv = getPhoneEnvBool('AND')
+
// const url = '/trapractice/voiceCallSocket/voiceCall'
// const url = '/traapp/voiceTransSocket/open';
-const url = '/traapp/voiceAiModelSocket/open';
+// const url = '/traapp/asrAiModelSocket/open';
+const url = '/traapp/asrAiModelSocket/open';
const _baseUrl = get_base_url(url).split('http').join('ws');
-// const _baseUrl = '';
export const wsUrl = _baseUrl + url + '?summary=' + getToken();
const windowInfo = uni.getWindowInfo();
// 安全区域信息
@@ -17,58 +19,48 @@ export const bottomAreaTopBoundary = safeArea.bottom - btnHeight;
// 屏幕横向分割线
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连接创建请求已发送');
- }
- });
+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: () => {
+ }
+ });
+
+ // 2. 监听连接成功(核心:连接成功时resolve,返回WS实例)
+ const onOpenHandler = (res) => {
+ resolve(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}`));
+ };
- // 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);
- });
+ // 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);
+ });
};
/**
@@ -78,19 +70,48 @@ export const initWebSocket = (wsUrl: string): Promise => {
* @returns 固定返回false(保持原有返回值类型)
*/
export function showErrorTip(
- errorCode: ErrorCode,
- options: ShowErrorTipOptions = {}
-): boolean {
- // 解构可选参数,设置默认延迟时间为200ms
- const { delayTime = 200 } = options;
+ errorCode : ErrorCode,
+ options : ShowErrorTipOptions = {}
+) : boolean {
+ // 解构可选参数,设置默认延迟时间为200ms
+ const { delayTime = 200 } = options;
- // 延迟显示对应的错误提示
- setTimeout(() => {
- uni.showToast({
- title: errorCode, // 直接使用枚举值作为提示文本
- icon: 'none'
- });
- }, delayTime); // 使用传入的延迟时间(或默认200ms)
+ // 延迟显示对应的错误提示
+ setTimeout(() => {
+ uni.showToast({
+ title: errorCode, // 直接使用枚举值作为提示文本
+ icon: 'none'
+ });
+ }, delayTime); // 使用传入的延迟时间(或默认200ms)
+
+ return false;
+}
+
+/**
+ * 跨平台震动反馈封装(适配安卓/iOS)轻震
+ * @returns {Promise} 是否成功触发震动
+ */
+export const vibrateLight = () : void => {
+ // #ifdef APP-PLUS
+ try {
+ if (IsAndEnv) {
+ uni.vibrateShort({
+ success: function () {
+ console.log('success');
+ }
+ });
+ } else {
+ let UIImpactFeedbackGenerator = plus.ios.importClass(
+ 'UIImpactFeedbackGenerator'
+ )
+ let impact = new UIImpactFeedbackGenerator()
+ impact.prepare()
+ impact.init(1)
+ impact.impactOccurred()
+ }
+ } catch (error) {
+ console.error('震动功能执行异常:', error);
+ }
+ // #endif
+}
- 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 a32303e4..7db8a762 100644
--- a/src/components/touch-btn/touch-btn.types.ts
+++ b/src/components/touch-btn/touch-btn.types.ts
@@ -6,7 +6,7 @@
* - 'text_input' : 文字输入中状态(文字输入面板展开,用户正在输入)
* - 'voice_to_text' : 语音转文字状态(独立的语音转文字页面/面板展开)
*/
-export type StatusType = 'default' | 'voice_recording' | 'text_input' | 'voice_to_text';
+export type StatusType = 'default' | 'voice_recording' | 'text_input' | 'voice_to_text' | 'voice_send_ing';
/**
* 语音转文字的子状态(仅当主状态为 voice_to_text 时生效)
@@ -14,7 +14,7 @@ export type StatusType = 'default' | 'voice_recording' | 'text_input' | 'voice_t
export type VoiceToTextSubStatus =
| '' // 纯空
| 'init' // 初始化(刚进入语音转文字页面)
- | 'loading' // 转换中(正在处理语音→文字)
+ // | 'loading' // 转换中(正在处理语音→文字)
| 'success' // 转换完成(成功得到文字结果)
| 'failed'; // 转换失败(网络/识别错误等)
@@ -31,4 +31,36 @@ export enum ErrorCode {
export interface ShowErrorTipOptions {
// 提示框延迟显示时间(毫秒),默认200ms
delayTime?: number;
-}
\ No newline at end of file
+}
+
+
+/**
+ * 触摸按钮组件的自定义事件类型定义
+ * 包含录音、提交、加载相关的所有事件及参数规范
+ */
+export type TouchBtnEmits = {
+ /**
+ * 提交语音内容(语音转文字完成后触发)
+ * @param voiceData - 语音相关数据(包含ossKey、识别文本等核心字段)
+ * @param duration - 录音时长(单位:毫秒),用于校验最短录音时长
+ */
+ submitVoice: [params: { ossKey: string; text: string }];
+ /**
+ * 提交文字答案(用户确认识别结果/手动编辑后触发)
+ * @param answerText - 最终提交的文字答案内容
+ * @param isManualEdit - 是否手动编辑过识别结果(true=手动修改,false=纯语音识别)
+ */
+ submitAnswer: [answerText: string, isManualEdit: boolean];
+ /**
+ * 开始录音(用户点击录音按钮时触发)
+ * @param recordParams - 录音初始化参数(可选,默认使用组件内置配置)
+ */
+ startRecord: [recordParams?: { sampleRate: number; channels: number }];
+ /**
+ * 发送加载状态(提交语音/文字时触发,控制父组件加载动画)
+ * @param isLoading - 是否处于加载中(true=显示加载,false=隐藏加载)
+ * @param loadingText - 加载提示文本(可选,默认:"处理中...")
+ */
+ sendLoading: [params: { recordId: string; loadingText?: string }];
+};
+
diff --git a/src/components/touch-btn/touch-btn.vue b/src/components/touch-btn/touch-btn.vue
index 316c6797..938eae05 100644
--- a/src/components/touch-btn/touch-btn.vue
+++ b/src/components/touch-btn/touch-btn.vue
@@ -1,29 +1,28 @@
-
+
+ :disabled="props.isDisabled" @click="disabled_click">
-
-