feat: add new features, update theme and build config
- Add 40+ new UI components including chat modules, discovery cards, photo galleries, FAQ and booking tools - Standardize brand color across all styles by replacing $theme-color-500 SCSS variables with #0ccd58 - Add sass 1.58.3 dependency and update vite config for modern scss compiler support - Refactor existing components (AddCarCrad, login page) and remove unused /quick/list router route - Add utility functions for URL parameter handling - Add static assets including custom znicons font, component images and icons - Fix scss syntax issues and deprecation warnings
This commit is contained in:
612
src/pages/home/components/ChatInputArea/index.vue
Normal file
612
src/pages/home/components/ChatInputArea/index.vue
Normal file
@@ -0,0 +1,612 @@
|
||||
<template>
|
||||
<div class="input-area-wrapper" @touchend="handleVoiceTouchEndFromContainer"
|
||||
@touchcancel="handleVoiceTouchEndFromContainer">
|
||||
<div v-if="!visibleWaveBtn || speechProvider !== 'wechat'" class="area-input">
|
||||
<!-- 语音/键盘切换 -->
|
||||
<div v-if="isSpeechRecognitionSupported" class="input-container-voice" @click="toggleVoiceMode">
|
||||
<img class="voice-icon" v-if="!isVoiceMode"
|
||||
src="https://oss.nianxx.cn/mp/static/version_101/home/input_voice_icon.png" />
|
||||
<img class="voice-icon" v-else src="https://oss.nianxx.cn/mp/static/version_101/home/input_keyboard_icon.png" />
|
||||
</div>
|
||||
|
||||
<!-- 输入框/语音按钮容器 -->
|
||||
<div class="input-button-container"
|
||||
:class="{ 'input-button-container--no-voice': !isSpeechRecognitionSupported }">
|
||||
<textarea ref="textareaRef" v-if="!isVoiceMode" class="textarea" type="text" cursor-spacing="20"
|
||||
confirm-type="send" v-model="inputMessage" auto-height :focus="isFocused" :confirm-hold="true"
|
||||
:placeholder="placeholder" :show-confirm-bar="false" :hold-keyboard="holdKeyboard" :adjust-position="true"
|
||||
:disable-default-padding="true" maxlength="300" @confirm="sendMessage" @focus="handleFocus" @blur="handleBlur"
|
||||
@touchstart="handleTouchStart" @touchend="handleTouchEnd" />
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<div v-if="isVoiceMode" class="hold-to-talk-button" @longpress="handleVoiceTouchStart"
|
||||
@touchend="handleVoiceTouchEnd" @touchcancel="handleVoiceTouchEnd">
|
||||
按住 说话
|
||||
</div>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<div v-if="isVoiceMode" class="hold-to-talk-button" @touchstart="handleVoiceTouchStart"
|
||||
@touchend="handleVoiceTouchEnd" @touchcancel="handleVoiceTouchEnd">
|
||||
<RecordingWaveBtn v-if="visibleWaveBtn" class="recording-wave-inline" ref="recordingWaveBtnRef" />
|
||||
<text v-else>按住 说话</text>
|
||||
</div>
|
||||
<!-- #endif -->
|
||||
</div>
|
||||
|
||||
<div class="input-container-send">
|
||||
<div class="input-container-send-btn" @click="sendMessage">
|
||||
<div v-if="props.isSessionActive" class="send-stop"> </div>
|
||||
<img v-else class="send-icon" src="https://oss.nianxx.cn/mp/static/version_101/home/input_send_icon.png" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 录音按钮 -->
|
||||
<RecordingWaveBtn v-if="visibleWaveBtn" ref="recordingWaveBtnRef" />
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<yao-asdRealSpeech v-if="isSpeechRecognitionSupported && appSpeechVisible" :key="appSpeechKey" ref="appSpeechRef"
|
||||
:options="appSpeechOptions" @result="handleAppSpeechResult" @change="handleAppSpeechChange" />
|
||||
<!-- #endif -->
|
||||
|
||||
<div class="color-99A0AE font-size-9 text-center text-gray-400">
|
||||
内容由AI大模型生成,请仔细鉴别
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, defineExpose, onUnmounted } from "vue";
|
||||
import RecordingWaveBtn from "@/components/Speech/RecordingWaveBtn.vue";
|
||||
import { chatInputPlaceholder } from "@/constant/local";
|
||||
// #ifdef APP-PLUS
|
||||
import { appSpeechRecognitionOptions } from "@/constant/speech";
|
||||
// #endif
|
||||
|
||||
let manager = null;
|
||||
let speechProvider = "";
|
||||
const isSpeechRecognitionEnabled = ref(true);
|
||||
const isSpeechRecognitionSupported = ref(false);
|
||||
let appSpeechOptions = {};
|
||||
// #ifdef APP-PLUS
|
||||
appSpeechOptions = appSpeechRecognitionOptions;
|
||||
// #endif
|
||||
|
||||
// WechatSI 是微信小程序插件,App 原生基座没有 requirePlugin。
|
||||
// #ifdef MP-WEIXIN
|
||||
const plugin = requirePlugin("WechatSI");
|
||||
manager = plugin.getRecordRecognitionManager();
|
||||
isSpeechRecognitionSupported.value = isSpeechRecognitionEnabled.value && !!manager;
|
||||
speechProvider = manager ? "wechat" : "";
|
||||
// #endif
|
||||
|
||||
// App 端使用 yao-asdRealSpeech;apikey 请在 src/constant/speech.js 中配置。
|
||||
// #ifdef APP-PLUS
|
||||
isSpeechRecognitionSupported.value = isSpeechRecognitionEnabled.value;
|
||||
speechProvider = "app";
|
||||
// #endif
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String,
|
||||
holdKeyboard: Boolean,
|
||||
isSessionActive: Boolean,
|
||||
stopRequest: Function,
|
||||
});
|
||||
const emit = defineEmits([
|
||||
"update:modelValue",
|
||||
"send",
|
||||
"noHideKeyboard",
|
||||
"keyboardShow",
|
||||
"keyboardHide",
|
||||
"sendVoice",
|
||||
]);
|
||||
|
||||
const textareaRef = ref(null);
|
||||
const recordingWaveBtnRef = ref(null);
|
||||
const appSpeechRef = ref(null);
|
||||
const appSpeechKey = ref(0);
|
||||
const appSpeechVisible = ref(true);
|
||||
const placeholder = computed(() => {
|
||||
return chatInputPlaceholder();
|
||||
});
|
||||
const inputMessage = ref(props.modelValue || "");
|
||||
const isFocused = ref(false);
|
||||
const keyboardHeight = ref(0);
|
||||
const isVoiceMode = ref(false);
|
||||
const visibleWaveBtn = ref(false);
|
||||
const isRecording = ref(false);
|
||||
const isVoicePressing = ref(false);
|
||||
const appRecognizedText = ref("");
|
||||
const isAppSpeechStarting = ref(false);
|
||||
const isAppWaitingToSend = ref(false);
|
||||
let watchDogTimer = null;
|
||||
let appStopFallbackTimer = null;
|
||||
let hasSentAppRecognition = false;
|
||||
|
||||
const resetUI = () => {
|
||||
isRecording.value = false;
|
||||
visibleWaveBtn.value = false;
|
||||
try {
|
||||
if (recordingWaveBtnRef.value) {
|
||||
recordingWaveBtnRef.value.stopAnimation();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("resetUI stopAnimation error", e);
|
||||
}
|
||||
if (watchDogTimer) {
|
||||
clearTimeout(watchDogTimer);
|
||||
watchDogTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startWatchDog = (timeout = 10000) => {
|
||||
if (watchDogTimer) clearTimeout(watchDogTimer);
|
||||
watchDogTimer = setTimeout(() => {
|
||||
watchDogTimer = null;
|
||||
console.warn("recording watchdog triggered, forcing UI reset");
|
||||
if (speechProvider === "app" && hasActiveVoiceRecognition()) {
|
||||
stopActiveVoiceRecognition({ shouldSend: true });
|
||||
return;
|
||||
}
|
||||
resetUI();
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
const clearAppStopFallback = () => {
|
||||
if (appStopFallbackTimer) {
|
||||
clearTimeout(appStopFallbackTimer);
|
||||
appStopFallbackTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showRecordingUI = () => {
|
||||
isRecording.value = true;
|
||||
visibleWaveBtn.value = true;
|
||||
|
||||
nextTick(() => {
|
||||
if (recordingWaveBtnRef.value) {
|
||||
recordingWaveBtnRef.value.startAnimation();
|
||||
}
|
||||
});
|
||||
startWatchDog(10000);
|
||||
};
|
||||
|
||||
const resetAppSpeechComponent = () => {
|
||||
if (speechProvider !== "app") return;
|
||||
|
||||
isAppSpeechStarting.value = false;
|
||||
appSpeechVisible.value = false;
|
||||
nextTick(() => {
|
||||
appSpeechKey.value += 1;
|
||||
appSpeechVisible.value = true;
|
||||
});
|
||||
};
|
||||
|
||||
const getPendingAppSpeechText = () => {
|
||||
return (appRecognizedText.value || inputMessage.value || "").trim();
|
||||
};
|
||||
|
||||
const scheduleAppRecognizedTextSend = (timeout = 1200) => {
|
||||
isAppWaitingToSend.value = true;
|
||||
clearAppStopFallback();
|
||||
|
||||
if (getPendingAppSpeechText()) {
|
||||
sendAppRecognizedText();
|
||||
return;
|
||||
}
|
||||
|
||||
appStopFallbackTimer = setTimeout(() => {
|
||||
appStopFallbackTimer = null;
|
||||
sendAppRecognizedText();
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
// 保持和父组件同步
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
inputMessage.value = val;
|
||||
}
|
||||
);
|
||||
|
||||
// 当子组件的 inputMessage 变化时,通知父组件(但要避免循环更新)
|
||||
watch(inputMessage, (val) => {
|
||||
// 只有当值真正不同时才emit,避免循环更新
|
||||
if (val !== props.modelValue) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
});
|
||||
|
||||
// 切换语音/文本模式
|
||||
const toggleVoiceMode = () => {
|
||||
if (!isSpeechRecognitionSupported.value) return;
|
||||
if (speechProvider === "app" && isVoiceMode.value) {
|
||||
stopActiveVoiceRecognition({ shouldSend: false });
|
||||
}
|
||||
isVoiceMode.value = !isVoiceMode.value;
|
||||
};
|
||||
|
||||
// 处理语音按钮按下开始
|
||||
const handleVoiceTouchStart = () => {
|
||||
if (!isSpeechRecognitionSupported.value) return;
|
||||
if (isRecording.value || isAppSpeechStarting.value || visibleWaveBtn.value) return;
|
||||
|
||||
isVoicePressing.value = true;
|
||||
|
||||
try {
|
||||
if (speechProvider === "wechat") {
|
||||
if (!manager) return;
|
||||
manager.start({ lang: "zh_CN" });
|
||||
showRecordingUI();
|
||||
} else if (speechProvider === "app") {
|
||||
if (!appSpeechOptions.apikey) {
|
||||
uni.showToast({
|
||||
title: "请先配置语音识别API Key",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
appRecognizedText.value = "";
|
||||
inputMessage.value = "";
|
||||
isAppWaitingToSend.value = false;
|
||||
hasSentAppRecognition = false;
|
||||
clearAppStopFallback();
|
||||
isAppSpeechStarting.value = true;
|
||||
|
||||
const appSpeech = appSpeechRef.value;
|
||||
if (!appSpeech || typeof appSpeech.start !== "function") {
|
||||
isAppSpeechStarting.value = false;
|
||||
uni.showToast({
|
||||
title: "语音组件未初始化",
|
||||
icon: "none",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
appSpeech.start();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("record start error:", err);
|
||||
isAppSpeechStarting.value = false;
|
||||
// 保底清理
|
||||
resetUI();
|
||||
}
|
||||
};
|
||||
|
||||
const hasActiveVoiceRecognition = () => {
|
||||
return (
|
||||
isVoicePressing.value ||
|
||||
isRecording.value ||
|
||||
isAppSpeechStarting.value ||
|
||||
visibleWaveBtn.value
|
||||
);
|
||||
};
|
||||
|
||||
const stopActiveVoiceRecognition = ({ shouldSend = true } = {}) => {
|
||||
isVoicePressing.value = false;
|
||||
|
||||
if (!isSpeechRecognitionSupported.value) {
|
||||
resetUI();
|
||||
return;
|
||||
}
|
||||
|
||||
if (speechProvider === "app") {
|
||||
const wasStarting = isAppSpeechStarting.value;
|
||||
const wasRecording = isRecording.value;
|
||||
|
||||
if (!wasStarting && !wasRecording) {
|
||||
if (!shouldSend) {
|
||||
isAppWaitingToSend.value = false;
|
||||
clearAppStopFallback();
|
||||
resetAppSpeechComponent();
|
||||
}
|
||||
resetUI();
|
||||
return false;
|
||||
}
|
||||
|
||||
isAppSpeechStarting.value = false;
|
||||
|
||||
try {
|
||||
appSpeechRef.value?.stop?.();
|
||||
if (wasRecording && shouldSend) {
|
||||
scheduleAppRecognizedTextSend();
|
||||
} else {
|
||||
isAppWaitingToSend.value = false;
|
||||
clearAppStopFallback();
|
||||
resetAppSpeechComponent();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("record stop error:", err);
|
||||
resetAppSpeechComponent();
|
||||
} finally {
|
||||
resetUI();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 如果本地状态不是录音中,也确保 UI 恢复
|
||||
if (!isRecording.value) {
|
||||
if (recordingWaveBtnRef.value) {
|
||||
recordingWaveBtnRef.value.stopAnimation();
|
||||
}
|
||||
visibleWaveBtn.value = false;
|
||||
if (watchDogTimer) {
|
||||
clearTimeout(watchDogTimer);
|
||||
watchDogTimer = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
manager?.stop?.();
|
||||
} catch (err) {
|
||||
console.error("record stop error:", err);
|
||||
} finally {
|
||||
// 无论 stop 是否抛错,都保证 UI 恢复
|
||||
resetUI();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 处理语音按钮长按结束
|
||||
const handleVoiceTouchEnd = () => {
|
||||
stopActiveVoiceRecognition({ shouldSend: true });
|
||||
};
|
||||
|
||||
const handleVoiceTouchEndFromContainer = () => {
|
||||
if (speechProvider !== "app") return;
|
||||
if (!hasActiveVoiceRecognition()) return;
|
||||
stopActiveVoiceRecognition({ shouldSend: true });
|
||||
};
|
||||
|
||||
// 处理发送原语音
|
||||
const initRecord = () => {
|
||||
if (!manager) return;
|
||||
|
||||
manager.onRecognize = (res) => {
|
||||
let text = res.result || "";
|
||||
inputMessage.value = text;
|
||||
};
|
||||
// 识别结束事件
|
||||
manager.onStop = (res) => {
|
||||
console.log(res, 37);
|
||||
let text = (res && res.result) || "";
|
||||
|
||||
// 保证 UI 恢复(防止未走 handleVoiceTouchEnd)
|
||||
resetUI();
|
||||
|
||||
if (text === "") {
|
||||
console.log("没有说话");
|
||||
return;
|
||||
}
|
||||
|
||||
inputMessage.value = text;
|
||||
// 在语音识别完成后发送消息
|
||||
emit("send", text);
|
||||
};
|
||||
|
||||
// 错误处理,确保 UI 重置
|
||||
manager.onError = (err) => {
|
||||
console.error("record manager error", err);
|
||||
resetUI();
|
||||
};
|
||||
};
|
||||
|
||||
const getAppSpeechText = (res) => {
|
||||
return (
|
||||
res?.sentence?.text ||
|
||||
res?.text ||
|
||||
res?.result ||
|
||||
res?.payload?.output?.sentence?.text ||
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
const sendAppRecognizedText = () => {
|
||||
if (hasSentAppRecognition) return false;
|
||||
|
||||
const text = getPendingAppSpeechText();
|
||||
if (!text) {
|
||||
console.log("没有说话");
|
||||
return false;
|
||||
}
|
||||
|
||||
hasSentAppRecognition = true;
|
||||
isAppWaitingToSend.value = false;
|
||||
clearAppStopFallback();
|
||||
inputMessage.value = text;
|
||||
emit("send", text);
|
||||
appRecognizedText.value = "";
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleAppSpeechResult = (res) => {
|
||||
if (hasSentAppRecognition) return;
|
||||
|
||||
const text = getAppSpeechText(res);
|
||||
if (!text) return;
|
||||
|
||||
appRecognizedText.value = text;
|
||||
inputMessage.value = text;
|
||||
|
||||
if (isAppWaitingToSend.value) {
|
||||
sendAppRecognizedText();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppSpeechChange = (msg) => {
|
||||
if (!msg || !msg.status) return;
|
||||
|
||||
if (msg.status === "START") {
|
||||
isAppSpeechStarting.value = false;
|
||||
if (!isVoicePressing.value) {
|
||||
appSpeechRef.value?.stop?.();
|
||||
resetAppSpeechComponent();
|
||||
resetUI();
|
||||
return;
|
||||
}
|
||||
|
||||
showRecordingUI();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.status === "STOP") {
|
||||
isAppSpeechStarting.value = false;
|
||||
resetUI();
|
||||
if (!sendAppRecognizedText() && !isAppWaitingToSend.value) {
|
||||
resetAppSpeechComponent();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.status === "FINISH") {
|
||||
isAppSpeechStarting.value = false;
|
||||
resetUI();
|
||||
sendAppRecognizedText();
|
||||
resetAppSpeechComponent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.status === "CLOSE") {
|
||||
isAppSpeechStarting.value = false;
|
||||
resetUI();
|
||||
if (isAppWaitingToSend.value) {
|
||||
sendAppRecognizedText();
|
||||
}
|
||||
resetAppSpeechComponent();
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.status === "ERROR") {
|
||||
console.error("app speech recognition error", msg.msg);
|
||||
isAppSpeechStarting.value = false;
|
||||
isAppWaitingToSend.value = false;
|
||||
clearAppStopFallback();
|
||||
resetUI();
|
||||
resetAppSpeechComponent();
|
||||
}
|
||||
};
|
||||
|
||||
// 监听键盘高度变化
|
||||
onMounted(() => {
|
||||
// 监听键盘弹起
|
||||
uni.onKeyboardHeightChange((res) => {
|
||||
keyboardHeight.value = res.height;
|
||||
if (res.height) {
|
||||
emit("keyboardShow", res.height);
|
||||
} else {
|
||||
emit("keyboardHide");
|
||||
}
|
||||
});
|
||||
|
||||
initRecord();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (manager) {
|
||||
try {
|
||||
manager.stop && manager.stop();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
manager.onRecognize = null;
|
||||
manager.onStop = null;
|
||||
manager.onError = null;
|
||||
}
|
||||
if (appSpeechRef.value) {
|
||||
try {
|
||||
appSpeechRef.value.stop && appSpeechRef.value.stop();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
clearAppStopFallback();
|
||||
isAppSpeechStarting.value = false;
|
||||
isAppWaitingToSend.value = false;
|
||||
isVoicePressing.value = false;
|
||||
resetUI();
|
||||
});
|
||||
|
||||
const hideKeyboardAfterSend = () => {
|
||||
isFocused.value = false;
|
||||
|
||||
const textarea = textareaRef.value;
|
||||
if (textarea && typeof textarea.blur === "function") {
|
||||
textarea.blur();
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
uni.hideKeyboard();
|
||||
});
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
if (props.isSessionActive) {
|
||||
// 如果会话进行中,调用停止请求函数
|
||||
if (props.stopRequest) {
|
||||
props.stopRequest();
|
||||
}
|
||||
hideKeyboardAfterSend();
|
||||
} else {
|
||||
// 否则发送新消息
|
||||
if (!inputMessage.value.trim()) return;
|
||||
emit("send", inputMessage.value);
|
||||
hideKeyboardAfterSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true;
|
||||
emit("noHideKeyboard");
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocused.value = false;
|
||||
};
|
||||
|
||||
const handleTouchStart = () => {
|
||||
emit("noHideKeyboard");
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
emit("noHideKeyboard");
|
||||
};
|
||||
|
||||
// 手动聚焦输入框
|
||||
const focusInput = () => {
|
||||
isFocused.value = true;
|
||||
nextTick(() => {
|
||||
const textarea = textareaRef.value;
|
||||
if (textarea && typeof textarea.focus === "function") {
|
||||
textarea.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 手动失焦输入框
|
||||
const blurInput = () => {
|
||||
isFocused.value = false;
|
||||
|
||||
const textarea = textareaRef.value;
|
||||
if (textarea && typeof textarea.blur === "function") {
|
||||
textarea.blur();
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
uni.hideKeyboard();
|
||||
});
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({ focusInput, blurInput });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
Reference in New Issue
Block a user