Merge remote-tracking branch 'origin/main'

This commit is contained in:
2026-02-02 13:22:25 +08:00
24 changed files with 2109 additions and 795 deletions

View File

@@ -61,6 +61,7 @@
<view class="main-header">
<image src="/static/icon/Hamburger-button.png" @click="toggleDrawer"></image>
<view class="title">{{ config.appInfo.areaName }}岗位推荐</view>
<!-- <view class="title">智能客服</view> -->
<image src="/static/icon/Comment-one.png" @click="addNewDialogue"></image>
</view>
</header>

View File

@@ -65,6 +65,26 @@ const centerIndex = ref(0);
// 动画帧ID
let animationId = null;
// 为小程序环境提供requestAnimationFrame兼容
const requestAnimationFramePolyfill = (callback) => {
// #ifdef MP-WEIXIN
return setTimeout(callback, 16); // 约60fps
// #endif
// #ifdef H5
return requestAnimationFrame(callback);
// #endif
};
// 为小程序环境提供cancelAnimationFrame兼容
const cancelAnimationFramePolyfill = (id) => {
// #ifdef MP-WEIXIN
clearTimeout(id);
// #endif
// #ifdef H5
cancelAnimationFrame(id);
// #endif
};
// 格式化显示时间
const formattedTime = computed(() => {
const mins = Math.floor(props.recordingTime / 60)
@@ -125,7 +145,7 @@ const updateWaveform = () => {
}
}
animationId = requestAnimationFrame(updateWaveform);
animationId = requestAnimationFramePolyfill(updateWaveform);
};
// 更新单个波形条
@@ -157,14 +177,14 @@ const updateWaveBar = (index, value) => {
// 开始动画
const startAnimation = () => {
if (!animationId) {
animationId = requestAnimationFrame(updateWaveform);
animationId = requestAnimationFramePolyfill(updateWaveform);
}
};
// 停止动画
const stopAnimation = () => {
if (animationId) {
cancelAnimationFrame(animationId);
cancelAnimationFramePolyfill(animationId);
animationId = null;
}
};

View File

@@ -133,6 +133,20 @@
<view class="chat-item self" v-if="isRecording">
<view class="message">{{ recognizedText }} {{ lastFinalText }}</view>
</view>
<!-- 语音正在识别提示 -->
<!-- <view>{{isRecognizing}}</view> -->
<view class="chat-item self" v-if="isRecognizing">
<view class="message msg-loading">
<view class="loading-content">
<view class="ai-loading">
<view></view>
<view></view>
<view></view>
</view>
<text class="loading-text">正在识别语音...</text>
</view>
</view>
</view>
<view v-if="isTyping" class="self">
<view class="message msg-loading">
<view class="loading-content">
@@ -175,9 +189,6 @@
@touchmove="handleTouchMove"
@touchend="handleTouchEnd"
@touchcancel="handleTouchCancel"
:catchtouchstart="true"
:catchtouchmove="true"
:catchtouchend="true"
v-show="isVoice"
type="default"
>
@@ -294,11 +305,11 @@ import FileIcon from './fileIcon.vue';
import FileText from './fileText.vue';
import { useAudioRecorder } from '@/hook/useRealtimeRecorder.js';
import { useTTSPlayer } from '@/hook/useTTSPlayer.js';
import successIcon from '@/static/icon/success.png';
// 全局
const { $api, navTo, throttle, config } = inject('globalFunction');
const emit = defineEmits(['onConfirm']);
const { messages, isTyping, textInput, chatSessionID } = storeToRefs(useChatGroupDBStore());
import successIcon from '@/static/icon/success.png';
// hook
const {
isRecording,
@@ -309,8 +320,32 @@ const {
volumeLevel,
recognizedText,
lastFinalText,
recordingDuration,
isRecognizing,
reset
} = useAudioRecorder();
// 监听语音识别结果变化,自动发送消息
watch(
() => recognizedText.value,
(newVal) => {
if (newVal && newVal.trim()) {
console.log('监听到语音识别结果变化,自动发送消息:', newVal);
sendMessage(newVal);
}
}
);
// 监听isRecognizing状态显示提示
watch(
() => isRecognizing.value,
(newVal) => {
if (newVal) {
$api.msg('正在识别语音...');
}
}
);
const { speak, pause, resume, isSpeaking, isPaused, cancelAudio } = useTTSPlayer(config.speechSynthesis);
// 获取组件实例(用于小程序 SelectorQuery
@@ -362,6 +397,7 @@ onMounted(async () => {
changeQueries();
scrollToBottom();
isAudioPermission.value = await requestMicPermission();
reset(); // 重置语音识别状态
});
const requestMicPermission = async () => {
@@ -443,11 +479,16 @@ const sendMessage = (text) => {
console.log('📝 Has job info:', hasJobInfo);
// 开始朗读当前消息
speechIndex.value = index;
readMarkdown(message.displayText, index);
speechIndex.value = index;
readMarkdown(message.displayText, index, { immediate: false });
// 一旦开始朗读就设置speechIndex避免重复调用
speechIndex.value = index;
} else {
console.log('⏳ Waiting for more content before TTS, current length:', message.displayText.length);
}
} else {
// 已经开始朗读这条消息,不再重复调用
console.log('⏭️ Already speaking this message, skipping duplicate TTS call');
}
}
},
@@ -472,7 +513,7 @@ const sendMessage = (text) => {
// 开始朗读完整的内容
speechIndex.value = lastMessageIndex;
readMarkdown(lastMessage.displayText, lastMessageIndex);
readMarkdown(lastMessage.displayText, lastMessageIndex, { immediate: true });
}
}
},
@@ -684,17 +725,22 @@ const handleTouchEnd = () => {
if (status.value === 'cancel') {
console.log('取消发送');
cancelRecording();
status.value = 'idle';
} else {
stopRecording();
if (isAudioPermission.value) {
if (recognizedText.value) {
sendMessage(recognizedText.value);
} else {
// 主要根据录音时长判断,而不是完全依赖识别结果
// 由于setInterval是异步的这里需要考虑计时延迟
const actualDuration = recordingDuration.value > 0 ? recordingDuration.value : (isRecording.value ? 0.5 : 0);
if (actualDuration < 1) {
$api.msg('说话时长太短');
status.value = 'idle';
} else {
// 状态管理由useAudioRecorder hook内部处理
status.value = 'idle';
}
}
}
status.value = 'idle';
};
const handleTouchCancel = () => {
@@ -740,7 +786,10 @@ function confirmFeeBack(value) {
// 防抖定时器
let ttsDebounceTimer = null;
function readMarkdown(value, index) {
// 保存上一次调用的文本内容避免重复调用TTS
let lastSpeechText = '';
function readMarkdown(value, index, options = {}) {
console.log('🎤 readMarkdown called');
console.log('📝 Text to speak:', value ? value.substring(0, 100) + '...' : 'No text');
console.log('🔢 Index:', index);
@@ -753,40 +802,37 @@ function readMarkdown(value, index) {
clearTimeout(ttsDebounceTimer);
}
// 如果当前正在播放其他消息,先停止
if (speechIndex.value !== index && speechIndex.value !== 0) {
console.log('🛑 Stopping current speech and starting new one');
speechIndex.value = index;
speak(value);
return;
}
// 总是先停止当前播放,无论是不是同一消息
console.log('🛑 Always stopping current speech before starting new one');
speechIndex.value = index;
// 如果当前正在播放且暂停了,直接恢复
if (isPaused.value && isSpeaking.value) {
console.log('▶️ Resuming paused speech');
resume();
return;
}
// 如果当前正在播放且没有暂停,不需要重新开始
if (isSpeaking.value && !isPaused.value) {
console.log('🔊 Already speaking, no need to restart');
return;
}
// 使用防抖避免频繁调用TTS
ttsDebounceTimer = setTimeout(() => {
console.log('🎵 Starting new speech');
console.log('🎵 Calling speak function with text length:', value ? value.length : 0);
try {
speak(value);
console.log('✅ Speak function called successfully');
} catch (error) {
console.error('❌ Error calling speak function:', error);
// 立即调用speak不使用防抖延迟
const speakNow = () => {
// 检查文本内容是否发生变化避免重复调用TTS
if (value !== lastSpeechText) {
console.log('🎵 Starting new speech');
console.log('🎵 Calling speak function with text length:', value ? value.length : 0);
try {
speak(value);
console.log('✅ Speak function called successfully');
// 更新上一次调用的文本内容
lastSpeechText = value;
} catch (error) {
console.error('❌ Error calling speak function:', error);
}
} else {
console.log('🔄 Same text as last speech, skipping duplicate TTS call');
}
}, 300); // 300ms防抖延迟
};
// 改进防抖逻辑,确保在短时间内只调用一次
if (options.immediate) {
// 如果是onComplete回调立即播放
speakNow();
} else {
// 对于流式数据,总是使用防抖,避免频繁调用
ttsDebounceTimer = setTimeout(speakNow, 500); // 延长防抖时间到500ms
}
}
function stopMarkdown(value, index) {
console.log('⏸️ stopMarkdown called for index:', index);
@@ -1118,6 +1164,11 @@ image-margin-top = 40rpx
-moz-user-select:none;
-ms-user-select:none;
touch-action: none; /* 禁用默认滚动 */
position: fixed;
left: 0;
right: 0;
bottom: 160rpx; /* 为底部导航栏留出空间 */
z-index: 9999; /* 确保高于其他元素 */
.record-tip
font-weight: 400;
color: #909090;

View File

@@ -1,5 +1,5 @@
<template>
<view class="app-container">
<view class="app-container" @touchstart="handleTouchStart" @touchmove="handleTouchMove">
<!-- #ifdef MP-WEIXIN -->
<!-- 小程序背景图片 -->
<image class="mp-background" src="/static/icon/background2.png" mode="aspectFill"></image>
@@ -110,7 +110,7 @@
<view class="service-icon service-icon-5">
<IconfontIcon name="jinengpeixun" :size="48" color="#FFFFFF" />
</view>
<view class="service-title">技能培训</view>
<view class="service-title">技能课堂</view>
</view>
<view class="service-item press-button" @click="handleServiceClick('skill-evaluation')">
<view class="service-icon service-icon-6">
@@ -136,12 +136,7 @@
</view>
<view class="service-title">虚拟面试</view>
</view>
<view class="service-item press-button" style="justify-content:normal" @click="goRc()">
<view class="service-icon service-icon-9">
<IconfontIcon name="Graduation-simple-" :size="32" color="#FFFFFF" />
</view>
<view class="service-title" style="overflow:unset">高校毕业生<br/>智慧就业服务</view>
</view>
<view class="service-item press-button" @click="handleServiceClick('career-planning')">
<view class="service-icon service-icon-11">
<image class="service-icon-img" src="/static/icon/antOutline.png" mode="aspectFit"></image>
@@ -185,6 +180,12 @@
</view>
<view class="service-title">评价机构信息</view>
</view>
<view class="service-item press-button" style="justify-content:normal" @click="goRc()">
<view class="service-icon service-icon-9">
<IconfontIcon name="Graduation-simple-" :size="32" color="#FFFFFF" />
</view>
<view class="service-title" style="overflow:unset">高校毕业生<br/>智慧就业服务</view>
</view>
</view>
<!-- #endif -->
</view>
@@ -229,7 +230,7 @@
<view class="nav-filter" :class="{ 'sticky-filter': shouldStickyFilter }" v-if="shouldShowJobSeekerContent">
<view class="filter-top" @touchmove.stop.prevent>
<view class="filter-top">
<scroll-view :scroll-x="true" :show-scrollbar="false" class="tab-scroll">
<view class="jobs-left">
<view
@@ -286,7 +287,6 @@
@scrolltolower="scrollBottom"
:enable-back-to-top="false"
:scroll-with-animation="false"
@touchmove.stop.prevent
>
<view class="falls" v-if="list.length">
<!-- #ifdef MP-WEIXIN -->
@@ -567,10 +567,48 @@ const lastScrollTop = ref(0);
const scrollTop = ref(0);
// 当用户与筛选/导航交互时,临时锁定头部显示状态,避免因数据刷新导致回弹显示
const isInteractingWithFilter = ref(false);
// 触摸事件状态
const touchStartY = ref(0);
const touchMoveY = ref(0);
// 滚动阈值配置
const HIDE_THRESHOLD = 50; // 隐藏顶部区域的滚动阈值(降低阈值,更容易触发)
const SHOW_THRESHOLD = 5; // 显示顶部区域的滚动阈值(接近顶部)
const STICKY_THRESHOLD = 80; // 筛选区域吸顶的滚动阈值
const TOUCH_MOVE_THRESHOLD = 30; // 触摸滑动阈值,用于判断是否为有效滑动
// 处理触摸开始事件
function handleTouchStart(e) {
// 记录触摸起始位置
touchStartY.value = e.touches[0].clientY;
}
// 处理触摸移动事件
function handleTouchMove(e) {
// 记录触摸移动位置
touchMoveY.value = e.touches[0].clientY;
// 计算滑动距离
const diffY = touchStartY.value - touchMoveY.value;
// 当向上滑动超过阈值时,隐藏顶部区域
if (diffY > TOUCH_MOVE_THRESHOLD) {
if (!shouldHideTop.value) {
shouldHideTop.value = true;
}
if (!shouldStickyFilter.value) {
shouldStickyFilter.value = true;
}
}
// 当向下滑动超过阈值且在顶部附近时,显示顶部区域
else if (diffY < -TOUCH_MOVE_THRESHOLD && scrollTop.value <= SHOW_THRESHOLD) {
if (shouldHideTop.value && !isInteractingWithFilter.value) {
shouldHideTop.value = false;
}
if (shouldStickyFilter.value) {
shouldStickyFilter.value = false;
}
}
}
// 简化的滚动处理函数
function handleScroll(e) {
@@ -1511,10 +1549,10 @@ defineExpose({ loadData });
color: #256BFA
// 服务功能网格样式
.service-grid
padding: 20rpx 28rpx
padding: 10rpx 28rpx
display: grid
grid-template-columns: 1fr 1fr 1fr 1fr
grid-gap: 20rpx
grid-gap: 10rpx
.service-item
display: flex
flex-direction: column
@@ -1522,12 +1560,12 @@ defineExpose({ loadData });
justify-content: center
height: 120rpx
background: transparent
padding: 10px 0px
padding: 2rpx 0px
.service-icon
width: 88rpx
height: 88rpx
border-radius: 12rpx
margin-bottom: 8rpx
width: 62rpx
height: 62rpx
border-radius: 10rpx
margin-bottom: 14rpx
flex-shrink: 0
.service-icon-1
background: linear-gradient(180deg, #FF8E8E 0%, #E53E3E 100%)

View File

@@ -47,6 +47,10 @@
<view class="mini-num">{{ counts.fairCollecitonCount }}</view>
<view class="mini-text">预约</view>
</view>
<view class="numbe-item button-click" @click="navTo('/packageA/pages/cancelApplication/cancelApplication')">
<view class="mini-num">{{ counts.applyCencalCount || 0 }}</view>
<view class="mini-text">取消投递</view>
</view>
</view>
<view class="mini-cards">
<view class="card-top btn-feel">