feat: 调整了目录结构
This commit is contained in:
70
src/pages/ChatMain/ChatCardAi/index.vue
Normal file
70
src/pages/ChatMain/ChatCardAi/index.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<view class="chat-ai">
|
||||
<view class="container-content">
|
||||
<image
|
||||
v-if="isLoading"
|
||||
class="loading-img"
|
||||
src="https://oss.nianxx.cn/mp/static/chat_msg_loading.gif"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<ChatMarkdown :key="textKey" :text="processedText" />
|
||||
<ChatLoading v-if="isLoading" />
|
||||
</view>
|
||||
<slot name="content"></slot>
|
||||
</view>
|
||||
<slot name="footer"></slot>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed, ref, watch } from "vue";
|
||||
import ChatMarkdown from "../ChatMarkdown/index.vue";
|
||||
import ChatLoading from "../ChatLoading/index.vue";
|
||||
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 用于强制重新渲染的key
|
||||
const textKey = ref(0);
|
||||
|
||||
// 处理文本内容
|
||||
const processedText = computed(() => {
|
||||
if (!props.text) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 确保文本是字符串类型
|
||||
const textStr = String(props.text);
|
||||
|
||||
// 处理加载状态的文本
|
||||
if (textStr.includes("思考中") || textStr.includes("...")) {
|
||||
return textStr;
|
||||
}
|
||||
|
||||
return textStr;
|
||||
});
|
||||
|
||||
// 监听text变化,强制重新渲染
|
||||
watch(
|
||||
() => props.text,
|
||||
(newText, oldText) => {
|
||||
if (newText !== oldText) {
|
||||
textKey.value++;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
29
src/pages/ChatMain/ChatCardAi/styles/index.scss
Normal file
29
src/pages/ChatMain/ChatCardAi/styles/index.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%; // ✅ 限制最大宽度
|
||||
overflow-x: hidden; // ✅ 防止横向撑开
|
||||
padding-bottom: 12px;
|
||||
|
||||
.chat-ai {
|
||||
margin: 6px 0;
|
||||
padding: 0 12px; // 消息内容的内边距 左右20px
|
||||
min-width: 100px;
|
||||
max-width: 100%; // ✅ 限制最大宽度
|
||||
overflow: hidden; // ✅ 超出内容被切掉
|
||||
word-wrap: break-word; // ✅ 长单词自动换行
|
||||
word-break: break-all; // ✅ 强制换行
|
||||
}
|
||||
}
|
||||
|
||||
.container-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
max-width: 100%; // ✅ 限制最大宽度
|
||||
}
|
||||
|
||||
.loading-img {
|
||||
margin-right: 8px;
|
||||
width: 30px;
|
||||
height: 25px;
|
||||
}
|
||||
20
src/pages/ChatMain/ChatCardMine/index.vue
Normal file
20
src/pages/ChatMain/ChatCardMine/index.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<view class="chat-mine bg-17294E">
|
||||
<text class="font-size-15 color-white">{{ text }}</text>
|
||||
<slot></slot>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
10
src/pages/ChatMain/ChatCardMine/styles/index.scss
Normal file
10
src/pages/ChatMain/ChatCardMine/styles/index.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.chat-mine {
|
||||
margin: 0 12px 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 15px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%; // ✅ 限制最大宽度
|
||||
overflow-x: hidden; // ✅ 防止横向撑开
|
||||
}
|
||||
18
src/pages/ChatMain/ChatCardOther/index.vue
Normal file
18
src/pages/ChatMain/ChatCardOther/index.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<view class="w-full border-box flex flex-col overflow-hidden pl-12 mt-6 mb-6">
|
||||
<text class="font-size-14 color-333">{{ text }}</text>
|
||||
<slot></slot>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
315
src/pages/ChatMain/ChatInputArea/index.vue
Normal file
315
src/pages/ChatMain/ChatInputArea/index.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<template>
|
||||
<view class="input-area-wrapper">
|
||||
<view v-if="!visibleWaveBtn" class="area-input">
|
||||
<!-- 语音/键盘切换 -->
|
||||
<view class="input-container-voice" @click="toggleVoiceMode">
|
||||
<image
|
||||
class="voice-icon"
|
||||
v-if="!isVoiceMode"
|
||||
src="https://oss.nianxx.cn/mp/static/version_101/home/input_voice_icon.png"
|
||||
/>
|
||||
<image
|
||||
class="voice-icon"
|
||||
v-else
|
||||
src="https://oss.nianxx.cn/mp/static/version_101/home/input_keyboard_icon.png"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 输入框/语音按钮容器 -->
|
||||
<view class="input-button-container">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-if="!isVoiceMode"
|
||||
class="textarea"
|
||||
type="text"
|
||||
cursor-spacing="20"
|
||||
confirm-type="send"
|
||||
v-model="inputMessage"
|
||||
auto-height
|
||||
: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"
|
||||
/>
|
||||
|
||||
<view
|
||||
v-if="isVoiceMode"
|
||||
class="hold-to-talk-button"
|
||||
@longpress="handleVoiceTouchStart"
|
||||
@touchend="handleVoiceTouchEnd"
|
||||
@touchcancel="handleVoiceTouchEnd"
|
||||
>
|
||||
按住 说话
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="input-container-send">
|
||||
<view class="input-container-send-btn" @click="sendMessage">
|
||||
<view v-if="props.isSessionActive" class="send-stop"> </view>
|
||||
<image
|
||||
v-else
|
||||
class="send-icon"
|
||||
src="https://oss.nianxx.cn/mp/static/version_101/home/input_send_icon.png"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 录音按钮 -->
|
||||
<RecordingWaveBtn v-if="visibleWaveBtn" ref="recordingWaveBtnRef" />
|
||||
|
||||
<view class="color-99A0AE font-size-9 text-center text-gray-400">
|
||||
内容由AI大模型生成,请仔细鉴别
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, defineExpose, onUnmounted } from "vue";
|
||||
import RecordingWaveBtn from "@/components/Speech/RecordingWaveBtn.vue";
|
||||
import { getCurrentConfig } from "@/constant/base";
|
||||
|
||||
const plugin = requirePlugin("WechatSI");
|
||||
const manager = plugin.getRecordRecognitionManager();
|
||||
|
||||
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 placeholder = computed(() => getCurrentConfig().placeholder);
|
||||
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);
|
||||
let watchDogTimer = null;
|
||||
|
||||
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(() => {
|
||||
console.warn("recording watchdog triggered, forcing UI reset");
|
||||
resetUI();
|
||||
}, timeout);
|
||||
};
|
||||
|
||||
// 保持和父组件同步
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
inputMessage.value = val;
|
||||
}
|
||||
);
|
||||
|
||||
// 当子组件的 inputMessage 变化时,通知父组件(但要避免循环更新)
|
||||
watch(inputMessage, (val) => {
|
||||
// 只有当值真正不同时才emit,避免循环更新
|
||||
if (val !== props.modelValue) {
|
||||
emit("update:modelValue", val);
|
||||
}
|
||||
});
|
||||
|
||||
// 切换语音/文本模式
|
||||
const toggleVoiceMode = () => {
|
||||
isVoiceMode.value = !isVoiceMode.value;
|
||||
};
|
||||
|
||||
// 处理语音按钮长按开始
|
||||
const handleVoiceTouchStart = () => {
|
||||
try {
|
||||
manager.start({ lang: "zh_CN" });
|
||||
isRecording.value = true;
|
||||
visibleWaveBtn.value = true;
|
||||
|
||||
// 启动音频条动画
|
||||
nextTick(() => {
|
||||
if (recordingWaveBtnRef.value) {
|
||||
recordingWaveBtnRef.value.startAnimation();
|
||||
}
|
||||
});
|
||||
startWatchDog(10000);
|
||||
} catch (err) {
|
||||
console.error("record start error:", err);
|
||||
// 保底清理
|
||||
resetUI();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理语音按钮长按结束
|
||||
const handleVoiceTouchEnd = () => {
|
||||
// 如果本地状态不是录音中,也确保 UI 恢复
|
||||
if (!isRecording.value) {
|
||||
if (recordingWaveBtnRef.value) {
|
||||
recordingWaveBtnRef.value.stopAnimation();
|
||||
}
|
||||
visibleWaveBtn.value = false;
|
||||
if (watchDogTimer) {
|
||||
clearTimeout(watchDogTimer);
|
||||
watchDogTimer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
manager.stop();
|
||||
} catch (err) {
|
||||
console.error("record stop error:", err);
|
||||
} finally {
|
||||
// 无论 stop 是否抛错,都保证 UI 恢复
|
||||
resetUI();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理发送原语音
|
||||
const initRecord = () => {
|
||||
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();
|
||||
};
|
||||
};
|
||||
|
||||
// 监听键盘高度变化
|
||||
onMounted(() => {
|
||||
// 监听键盘弹起
|
||||
uni.onKeyboardHeightChange((res) => {
|
||||
keyboardHeight.value = res.height;
|
||||
if (res.height) {
|
||||
emit("keyboardShow", res.height);
|
||||
} else {
|
||||
emit("keyboardHide");
|
||||
}
|
||||
});
|
||||
|
||||
initRecord();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
try {
|
||||
manager.stop && manager.stop();
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
manager.onRecognize = null;
|
||||
manager.onStop = null;
|
||||
manager.onError = null;
|
||||
resetUI();
|
||||
});
|
||||
|
||||
const sendMessage = () => {
|
||||
if (props.isSessionActive) {
|
||||
// 如果会话进行中,调用停止请求函数
|
||||
if (props.stopRequest) {
|
||||
props.stopRequest();
|
||||
}
|
||||
} else {
|
||||
// 否则发送新消息
|
||||
if (!inputMessage.value.trim()) return;
|
||||
emit("send", inputMessage.value);
|
||||
|
||||
// 发送后保持焦点(可选)
|
||||
if (props.holdKeyboard && textareaRef.value) {
|
||||
nextTick(() => {
|
||||
textareaRef.value.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
isFocused.value = true;
|
||||
emit("noHideKeyboard");
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocused.value = false;
|
||||
};
|
||||
|
||||
const handleTouchStart = () => {
|
||||
emit("noHideKeyboard");
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
emit("noHideKeyboard");
|
||||
};
|
||||
|
||||
// 手动聚焦输入框
|
||||
const focusInput = () => {
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 手动失焦输入框
|
||||
const blurInput = () => {
|
||||
if (textareaRef.value) {
|
||||
textareaRef.value.blur();
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({ focusInput });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
103
src/pages/ChatMain/ChatInputArea/styles/index.scss
Normal file
103
src/pages/ChatMain/ChatInputArea/styles/index.scss
Normal file
@@ -0,0 +1,103 @@
|
||||
.area-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 24px;
|
||||
background-color: $uni-bg-color;
|
||||
box-shadow: 0px 0px 20px 0px rgba(52, 25, 204, 0.05);
|
||||
margin: 0 12px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.input-container-voice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-end;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
.voice-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.input-button-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hold-to-talk-button {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
color: $uni-text-color;
|
||||
font-size: $uni-font-size-lg;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: $uni-bg-color;
|
||||
transition: all 0.2s ease;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.input-container-send {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-end;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
.input-container-send-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
39% 39% at 97% 81%,
|
||||
#79dffb 0%,
|
||||
rgba(138, 227, 252, 0) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
54% 54% at 3% 70%,
|
||||
#8afcf8 0%,
|
||||
rgba(138, 252, 248, 0) 100%
|
||||
),
|
||||
$theme-color-500;
|
||||
}
|
||||
|
||||
.send-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.send-stop {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #ffffff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.textarea {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-height: 92px;
|
||||
min-height: 22px;
|
||||
font-size: $uni-font-size-lg;
|
||||
line-height: 22px;
|
||||
margin: 6px 0;
|
||||
|
||||
&::placeholder {
|
||||
color: #cccccc;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
src/pages/ChatMain/ChatLoading/index.vue
Normal file
47
src/pages/ChatMain/ChatLoading/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<view class="wave">
|
||||
<view class="dot"></view>
|
||||
<view class="dot"></view>
|
||||
<view class="dot"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script></script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.wave {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
width: 30px;
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: $uni-border-radius-circle;
|
||||
margin-right: 3px;
|
||||
background: #333333;
|
||||
animation: wave 1.3s linear infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: -1.1s;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
animation-delay: -0.9s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: initial;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
195
src/pages/ChatMain/ChatLongAnswer/index.vue
Normal file
195
src/pages/ChatMain/ChatLongAnswer/index.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<view class="flex flex-col bg-liner h-screen overflow-hidden">
|
||||
<!-- ✅ 顶部固定导航 -->
|
||||
<view class="flex-shrink-0">
|
||||
<TopNavBar :title="title" background="transparent" />
|
||||
</view>
|
||||
|
||||
<!-- ✅ 滚动区域 -->
|
||||
<scroll-view class="flex-full overflow-hidden chat-scroll" scroll-y :scroll-into-view="scrollIntoViewId" scroll-with-animation @scroll="onScroll" @touchstart="onTouchStart" @touchend="onTouchEnd" @touchcancel="onTouchEnd">
|
||||
<view class="pt-12 px-12 pb-24 border-box">
|
||||
<!-- ✅ 内容,支持markdown -->
|
||||
<ChatMarkdown :text="answerText" />
|
||||
|
||||
<!-- ✅ 底部锚点(必须存在) -->
|
||||
<view id="bottom-anchor"></view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import ChatMarkdown from "../ChatMarkdown/index.vue";
|
||||
import { defineProps, ref, nextTick } from "vue";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import StreamManager from "@/utils/StreamManager.js";
|
||||
|
||||
const props = defineProps({
|
||||
answerText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const answerText = ref(props.answerText || "");
|
||||
const title = ref("");
|
||||
|
||||
let unsubscribe = null;
|
||||
|
||||
/** ✅ scroll-into-view 控制 */
|
||||
const scrollIntoViewId = ref("");
|
||||
|
||||
/** 滚动控制状态 */
|
||||
const isNearBottom = ref(true);
|
||||
const scrollViewHeight = ref(0);
|
||||
const SCROLL_THRESHOLD = 150; // px
|
||||
|
||||
/** 用户交互状态,用户滚动/触摸时临时禁用自动滚动 */
|
||||
const userInteracting = ref(false);
|
||||
let interactionTimer = null;
|
||||
|
||||
/** 是否已完成(从 URL 参数判断),完成状态下不初始初自动滚到底部 */
|
||||
let isFinishedOnInit = false;
|
||||
|
||||
/** ✅ 防抖 */
|
||||
let scrollTimer = null;
|
||||
|
||||
const measureScrollViewHeight = () => {
|
||||
try {
|
||||
// 使用 uni.createSelectorQuery 获取 scroll-view 的准确高度
|
||||
uni.createSelectorQuery()
|
||||
.select(".chat-scroll")
|
||||
.boundingClientRect((rect) => {
|
||||
if (rect && rect.height) {
|
||||
scrollViewHeight.value = rect.height;
|
||||
}
|
||||
})
|
||||
.exec();
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
/** 生成展示用标题:去除前导 `#` 并截取前 6 字符(超过加省略号) */
|
||||
const computeTitle = (text = "") => {
|
||||
const t = (text || "").replace(/^#+\s*/, "");
|
||||
return t.length > 8 ? t.substring(0, 8) + "..." : t;
|
||||
}
|
||||
|
||||
const onScroll = (e) => {
|
||||
try {
|
||||
const { scrollTop = 0, scrollHeight = 0 } = e.detail || {};
|
||||
|
||||
// 计算距离底部的距离(使用准确的 scroll-view 高度)
|
||||
const viewHeight = scrollViewHeight.value;
|
||||
const distanceToBottom = scrollHeight - scrollTop - viewHeight;
|
||||
|
||||
// 判断是否在底部附近(允许 SCROLL_THRESHOLD 的误差范围)
|
||||
// 注意:只更新 isNearBottom,不在滚动时强制改变 userInteracting
|
||||
const atBottom = distanceToBottom <= SCROLL_THRESHOLD;
|
||||
isNearBottom.value = atBottom;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const onTouchStart = () => {
|
||||
// 触摸开始时,立即标记为用户交互状态
|
||||
userInteracting.value = true;
|
||||
clearTimeout(interactionTimer);
|
||||
}
|
||||
|
||||
const onTouchEnd = () => {
|
||||
// 触摸结束后延迟一段时间再取消交互状态
|
||||
// 这样即使用户快速滚动,也不会被中途打断
|
||||
clearTimeout(interactionTimer);
|
||||
interactionTimer = setTimeout(() => {
|
||||
userInteracting.value = false;
|
||||
}, 600);
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollTimer) return;
|
||||
if (isFinishedOnInit) return;
|
||||
|
||||
scrollTimer = setTimeout(() => {
|
||||
// ❗关键:强制触发滚动(小程序必须这样)
|
||||
// 如果用户正在交互,则跳过本次自动滚动
|
||||
if (userInteracting.value) {
|
||||
scrollTimer = null;
|
||||
return;
|
||||
}
|
||||
|
||||
scrollIntoViewId.value = "";
|
||||
|
||||
nextTick(() => {
|
||||
// 再次 nextTick + 延迟,兼容 markdown 渲染延迟
|
||||
setTimeout(() => {
|
||||
scrollIntoViewId.value = "bottom-anchor";
|
||||
// 测量高度以便后续滚动判断准确
|
||||
measureScrollViewHeight();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
scrollTimer = null;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
onLoad(({ message = "", streamId = "", finished = "0" }) => {
|
||||
// 记录初始完成状态
|
||||
isFinishedOnInit = finished === "1";
|
||||
|
||||
console.log("LongAnswer onLoad with params:", { message, streamId, finished });
|
||||
|
||||
// 初次测量 scroll-view 高度
|
||||
nextTick(() => {
|
||||
measureScrollViewHeight();
|
||||
});
|
||||
|
||||
if (streamId) {
|
||||
// ✅ 流式数据
|
||||
unsubscribe = StreamManager.subscribe(
|
||||
streamId,
|
||||
(text = "", finished = false) => {
|
||||
answerText.value = text || "";
|
||||
title.value = computeTitle(answerText.value);
|
||||
|
||||
nextTick(() => {
|
||||
// 每次接收数据都重新测量高度(content size 可能变化,比如加载图)
|
||||
measureScrollViewHeight();
|
||||
|
||||
// 流式完成时强制滚动到底部
|
||||
if (finished) {
|
||||
scrollToBottom();
|
||||
}
|
||||
// 流式中的数据更新:只有在用户未交互且接近底部时才自动滚动
|
||||
else if (!userInteracting.value && isNearBottom.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// ✅ 非流式
|
||||
answerText.value = decodeURIComponent(message || "");
|
||||
title.value = computeTitle(answerText.value);
|
||||
|
||||
nextTick(() => {
|
||||
// 只有在初始化为非完成状态时才自动滚到底部
|
||||
scrollToBottom();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
try {
|
||||
unsubscribe && unsubscribe();
|
||||
} catch (e) { }
|
||||
// 清理定时器,避免内存泄漏
|
||||
try { clearTimeout(scrollTimer); } catch (e) { }
|
||||
try { clearTimeout(interactionTimer); } catch (e) { }
|
||||
scrollTimer = null;
|
||||
interactionTimer = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
1193
src/pages/ChatMain/ChatMainList/index.vue
Normal file
1193
src/pages/ChatMain/ChatMainList/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
27
src/pages/ChatMain/ChatMarkdown/index.vue
Normal file
27
src/pages/ChatMain/ChatMarkdown/index.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<view class="container">
|
||||
<zero-markdown-view :markdown="text" :aiMode="true"></zero-markdown-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
.container ::v-deep image,
|
||||
.container ::v-deep video,
|
||||
.container ::v-deep iframe {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
38
src/pages/ChatMain/ChatMoreTips/index.vue
Normal file
38
src/pages/ChatMain/ChatMoreTips/index.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<view class="more-tips bg-white border-box">
|
||||
<view class="font-size-0 whitespace-nowrap scroll-x">
|
||||
<view
|
||||
class="more-tips-item border-box inline-block mr-8"
|
||||
v-for="(item, index) in guideWords"
|
||||
:key="index"
|
||||
>
|
||||
<text
|
||||
:class="['font-500 font-size-12 text-center', `color-${index}`]"
|
||||
@click="sendReply(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { SEND_MESSAGE_CONTENT_TEXT } from "@/constant/constant";
|
||||
import { defineProps } from "vue";
|
||||
|
||||
defineProps({
|
||||
guideWords: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
});
|
||||
|
||||
const sendReply = (item) => {
|
||||
uni.$emit(SEND_MESSAGE_CONTENT_TEXT, item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
44
src/pages/ChatMain/ChatMoreTips/styles/index.scss
Normal file
44
src/pages/ChatMain/ChatMoreTips/styles/index.scss
Normal file
@@ -0,0 +1,44 @@
|
||||
.more-tips {
|
||||
box-shadow: 0px -1px 10px 0px rgba(0, 0, 0, 0.03);
|
||||
border-radius: 0px 0px 20px 20px;
|
||||
padding: 12px 0 12px 12px;
|
||||
|
||||
.more-tips-item {
|
||||
background: #f7f7f7;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.color-0 {
|
||||
color: #47c2ff;
|
||||
}
|
||||
|
||||
.color-1 {
|
||||
color: #fb3748;
|
||||
}
|
||||
|
||||
.color-2 {
|
||||
color: #1fc16b;
|
||||
}
|
||||
|
||||
.color-3 {
|
||||
color: #f6b51e;
|
||||
}
|
||||
|
||||
.color-4 {
|
||||
color: #7d52f4;
|
||||
}
|
||||
|
||||
.color-5 {
|
||||
color: #fb4ba3;
|
||||
}
|
||||
|
||||
.color-6 {
|
||||
color: #22d3bb;
|
||||
}
|
||||
65
src/pages/ChatMain/ChatQuickAccess/index.vue
Normal file
65
src/pages/ChatMain/ChatQuickAccess/index.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<view class="quick-access flex flex-row ml-12 pt-8 pb-8 scroll-x whitespace-nowrap">
|
||||
<view class="item border-box rounded-50 flex flex-row items-center" v-for="(item, index) in itemList" :key="index"
|
||||
@click="sendReply(item)">
|
||||
<view class="flex items-center justify-center">
|
||||
<image v-if="item.icon" class="icon" :src="item.icon" />
|
||||
<text class="font-size-14 theme-color-500 line-height-20">
|
||||
{{ item.title }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { Command } from "@/model/ChatModel";
|
||||
import { SEND_MESSAGE_COMMAND_TYPE } from "@/constant/constant";
|
||||
import { checkToken } from "@/hooks/useGoLogin";
|
||||
|
||||
const itemList = ref([
|
||||
{
|
||||
icon: "",
|
||||
title: "快速预定",
|
||||
type: Command.quickBooking,
|
||||
},
|
||||
{
|
||||
icon: "",
|
||||
title: "探索发现",
|
||||
type: Command.discovery,
|
||||
},
|
||||
{
|
||||
icon: "",
|
||||
title: "呼叫服务",
|
||||
type: Command.callServiceCard,
|
||||
},
|
||||
{
|
||||
icon: "https://oss.nianxx.cn/mp/static/version_101/home/more.png",
|
||||
title: "更多",
|
||||
type: Command.more,
|
||||
},
|
||||
]);
|
||||
|
||||
const sendReply = (item) => {
|
||||
// 更多服务
|
||||
if (item.type === Command.more) {
|
||||
uni.$emit("SHOW_MORE_POPUP");
|
||||
return;
|
||||
}
|
||||
|
||||
// 快速预定
|
||||
if (item.type === Command.quickBooking) {
|
||||
checkToken().then(() => {
|
||||
uni.navigateTo({ url: "/pages-quick/list" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
uni.$emit(SEND_MESSAGE_COMMAND_TYPE, item);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
15
src/pages/ChatMain/ChatQuickAccess/styles/index.scss
Normal file
15
src/pages/ChatMain/ChatQuickAccess/styles/index.scss
Normal file
@@ -0,0 +1,15 @@
|
||||
.quick-access {
|
||||
gap: 0 9px;
|
||||
}
|
||||
|
||||
.item {
|
||||
border: 1px solid #fff;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
padding: 4px 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
64
src/pages/ChatMain/ChatTopNavBar/index.vue
Normal file
64
src/pages/ChatMain/ChatTopNavBar/index.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<view class="border-box h-44 flex flex-items-center pl-12 pr-12">
|
||||
<uni-icons type="bars" size="24" color="#333" @click="showDrawer" />
|
||||
|
||||
<!-- 隐藏 -->
|
||||
<view class="flex-full h-full flex flex-items-center flex-justify-center">
|
||||
<!-- ChatTopWelcome不在可视区:显示并添加动画;在可视区:隐藏 -->
|
||||
<SpriteAnimator v-show="show" class="image-animated" :src="spriteStyle.ipSmallImage"
|
||||
:frameWidth="spriteStyle.frameWidth" :frameHeight="spriteStyle.frameHeight"
|
||||
:totalFrames="spriteStyle.totalFrames" :columns="spriteStyle.columns" :displayWidth="spriteStyle.displayWidth"
|
||||
:fps="16" />
|
||||
<text v-show="show" :class="[
|
||||
'font-size-14 font-500 color-171717 ml-10',
|
||||
{ 'text-animated': show },
|
||||
]">
|
||||
{{ config.name }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<view class="w-24 h-24"></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, computed, defineExpose } from "vue";
|
||||
import { getCurrentConfig } from "@/constant/base";
|
||||
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
|
||||
|
||||
const props = defineProps({
|
||||
mainPageDataModel: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
initPageImages: {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const initPageImages = computed(() => {
|
||||
return props.mainPageDataModel?.initPageImages || {};
|
||||
});
|
||||
|
||||
const show = ref(false);
|
||||
const config = getCurrentConfig();
|
||||
|
||||
const spriteStyle = computed(() => {
|
||||
const images = initPageImages.value;
|
||||
return {
|
||||
ipSmallImage: images.ipSmallImage ?? config.ipSmallImage,
|
||||
frameWidth: images.ipSmallImageWidth ?? config.ipSmallImageWidth,
|
||||
frameHeight: images.ipSmallImageHeight ?? config.ipSmallImageHeight,
|
||||
totalFrames: images.ipSmallTotalFrames ?? config.ipSmallTotalFrames,
|
||||
columns: images.ipSmallColumns ?? config.ipSmallColumns,
|
||||
displayWidth: 32,
|
||||
};
|
||||
});
|
||||
|
||||
const showDrawer = () => uni.$emit("SHOW_DRAWER");
|
||||
|
||||
defineExpose({ show });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
31
src/pages/ChatMain/ChatTopNavBar/styles/index.scss
Normal file
31
src/pages/ChatMain/ChatTopNavBar/styles/index.scss
Normal file
@@ -0,0 +1,31 @@
|
||||
// 图片从0%到100%动画
|
||||
.image-animated {
|
||||
animation: logo-scale 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes logo-scale {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 文字从0%到100%动画,从左到右
|
||||
.text-animated {
|
||||
animation: text-fade-in 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes text-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
94
src/pages/ChatMain/ChatTopWelcome/index.vue
Normal file
94
src/pages/ChatMain/ChatTopWelcome/index.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
v
|
||||
<template>
|
||||
<view class="welcome-content border-box p-12">
|
||||
<view class="wrap rounded-20">
|
||||
<view class="flex flex-items-center flex-justify-between border-box pl-12 pr-12">
|
||||
<SpriteAnimator :src="spriteStyle.ipLargeImage" :frameWidth="spriteStyle.frameWidth"
|
||||
:frameHeight="spriteStyle.frameHeight" :totalFrames="spriteStyle.totalFrames" :columns="spriteStyle.columns"
|
||||
:displayWidth="spriteStyle.displayWidth" :fps="16" />
|
||||
<view class="welcome-text font-size-14 font-500 font-family-misans-vf color-171717 line-height-24">
|
||||
{{ welcomeContent }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<ChatMoreTips :guideWords="guideWords" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, computed, getCurrentInstance, defineExpose } from "vue";
|
||||
import { getCurrentConfig } from "@/constant/base";
|
||||
import ChatMoreTips from "../ChatMoreTips/index.vue";
|
||||
import SpriteAnimator from "@/components/Sprite/SpriteAnimator.vue";
|
||||
|
||||
const props = defineProps({
|
||||
mainPageDataModel: {
|
||||
type: Object,
|
||||
default: () => {
|
||||
return {
|
||||
initPageImages: {
|
||||
backgroundImageUrl: "",
|
||||
logoImageUrl: "",
|
||||
welcomeImageUrl: "",
|
||||
},
|
||||
welcomeContent:
|
||||
"查信息、预定下单、探索玩法、呼叫服务、我通通可以满足,快试试问我问题吧!",
|
||||
guideWords: [
|
||||
"定温泉票",
|
||||
"定酒店",
|
||||
"优惠套餐",
|
||||
"亲子玩法",
|
||||
"了解交通",
|
||||
"看看酒店",
|
||||
"看看美食",
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const initPageImages = computed(() => {
|
||||
return props.mainPageDataModel?.initPageImages || {};
|
||||
});
|
||||
|
||||
const config = getCurrentConfig();
|
||||
|
||||
const spriteStyle = computed(() => {
|
||||
const images = initPageImages.value;
|
||||
return {
|
||||
ipLargeImage: images.ipLargeImage ?? config.ipLargeImage,
|
||||
frameWidth: images.ipLargeImageWidth ?? config.ipLargeImageWidth,
|
||||
frameHeight: images.ipLargeImageHeight ?? config.ipLargeImageHeight,
|
||||
totalFrames: images.ipLargeTotalFrames ?? config.ipLargeTotalFrames,
|
||||
columns: images.ipLargeColumns ?? config.ipLargeColumns,
|
||||
displayWidth: 158,
|
||||
};
|
||||
});
|
||||
|
||||
const welcomeContent = computed(() => props.mainPageDataModel.welcomeContent);
|
||||
const guideWords = computed(() => props.mainPageDataModel.guideWords);
|
||||
|
||||
// Welcome 可视状态与高度
|
||||
const instance = getCurrentInstance();
|
||||
|
||||
// 测量欢迎区域高度
|
||||
const measureWelcomeHeight = (callback) => {
|
||||
uni
|
||||
.createSelectorQuery()
|
||||
.in(instance)
|
||||
.select(".welcome-content")
|
||||
.boundingClientRect((res) => {
|
||||
if (res && res.height) {
|
||||
callback(res);
|
||||
}
|
||||
})
|
||||
.exec();
|
||||
};
|
||||
|
||||
defineExpose({ measureWelcomeHeight });
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./styles/index.scss";
|
||||
</style>
|
||||
3
src/pages/ChatMain/ChatTopWelcome/styles/index.scss
Normal file
3
src/pages/ChatMain/ChatTopWelcome/styles/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.wrap {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
40
src/pages/ChatMain/NoticeMessage/index.vue
Normal file
40
src/pages/ChatMain/NoticeMessage/index.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
|
||||
<view class="border-box pl-12 pr-12">
|
||||
<view class="wrap rounded-20 bg-white relative overflow-hidden">
|
||||
<view class="flex flex-col p-16 mr-110 border-box">
|
||||
<view class="flex flex-row flex-items-center justify-center">
|
||||
<view class="w-24 h-24 rounded-full bg-theme-color-500 flex flex-items-center flex-justify-center">
|
||||
<uni-icons type="sound" size="16" color="#ffffff"/>
|
||||
</view>
|
||||
<text class="font-size-16 font-500 text-color-900 ml-6">临时提醒</text>
|
||||
</view>
|
||||
<view class="font-size-12 font-color-600 mt-8">{{ props.noticeContent.eventMessageContent }}</view>
|
||||
</view>
|
||||
<image class="mt-4 object-cover absolute right-0 bottom-0 bg-image" src="./notice_bg_img.png"/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
noticeContent: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bg-image {
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
}
|
||||
.mr-110 {
|
||||
margin-right: 110px;
|
||||
}
|
||||
</style>
|
||||
BIN
src/pages/ChatMain/NoticeMessage/notice_bg_img.png
Normal file
BIN
src/pages/ChatMain/NoticeMessage/notice_bg_img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 178 KiB |
Reference in New Issue
Block a user