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:
duanshuwen
2026-05-26 22:49:52 +08:00
parent 548df7020c
commit ac8f5b5f64
159 changed files with 12439 additions and 629 deletions

View 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-asdRealSpeechapikey 请在 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>