feat: 调整了目录结构

This commit is contained in:
2026-04-23 16:37:26 +08:00
parent 8161e7512b
commit 736c2feb4f
58 changed files with 2370 additions and 373 deletions

View 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>

View 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;
}

View 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>

View 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; // ✅ 防止横向撑开
}

View 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>

View 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>

View 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;
}
}
}

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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;
}

View 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>

View 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;
}

View 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>

View 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);
}
}

View 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>

View File

@@ -0,0 +1,3 @@
.wrap {
background-color: rgba(255, 255, 255, 0.5);
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB