uniapp AI聊天页面
已关注
关注
重播 分享 赞
typingMessages对象管理每个消息的打字状态,使用递归setTimeout实现逐字输出,同时配合$forceUpdate强制刷新视图,营造AI正在思考并逐字回复的沉浸体验。uni.request发起HTTPS请求,携带用户消息获取AI回复。-
顶部导航栏适配小程序胶囊按钮高度
-
消息时间戳格式化显示
-
提示词区域横向滚动,提供快捷输入
-
发送按钮根据输入内容动态高亮
-
录音时全局遮罩,防止误触
源码
<template><viewclass="chat-container"><!-- 顶部导航栏 - 风格,与胶囊按钮对齐 --><viewclass="chat-header":style="{ paddingTop: statusBarHeight + 'px', height: headerHeight + 'px' }"><viewclass="header-left" @click="goBack"><imagesrc="/static/back.png"class="func-img"mode="aspectFit"></image></view><viewclass="header-center"><textclass="bot-name"></text><viewclass="status-badge"><textclass="status-dot"></text><textclass="status-text">智能助手</text></view></view><viewclass="header-right"><textclass="more-icon">⋯</text></view></view><!-- 聊天内容区域 --><scroll-viewscroll-yclass="chat-content":scroll-top="scrollTop" @scrolltoupper="loadMoreMessages":scroll-with-animation="true":enable-back-to-top="true":scroll-y="true":throttle="false"><viewclass="chat-messages"><!-- 时间分隔 --><viewclass="time-divider"v-if="showDateDivider"><text>{{ today }}</text></view><!-- 消息列表 --><viewv-for="(message, index) in messages":key="message.id"><!-- AI消息 --><viewv-if="message.sender !== 'me'"class="message-item other-message"><viewclass="message-content ai-message-content"><viewclass="message-bubble other-bubble ai-bubble"><!-- 图片消息 --><imagev-if="message.imageUrl":src="message.imageUrl"class="message-image"mode="widthFix" @click="previewImage(message.imageUrl)"></image><!-- 文本消息 --><textv-elseclass="message-text ai-message-text">{{ displayContent(message) }}</text><viewclass="typing-indicator"v-if="message.typing"><viewclass="dot"></view><viewclass="dot"></view><viewclass="dot"></view></view></view><viewclass="message-time"> {{ formatTime(message.time) }}</view></view></view><!-- 用户消息 --><viewv-elseclass="message-item my-message"><viewclass="message-content my-content"><!-- 语音消息 --><viewv-if="message.voiceUrl"class="message-bubble my-bubble voice-bubble" @click="playVoice(message)"><imagesrc="/static/voice-icon.png"class="voice-img"mode="aspectFit"></image><textclass="voice-duration">{{ message.duration || 0 }}"</text></view><!-- 图片消息 --><viewv-else-if="message.imageUrl"class="message-bubble my-bubble image-bubble" @click="previewImage(message.imageUrl)"><image:src="message.imageUrl"class="message-image"mode="widthFix"></image></view><!-- 文本消息 --><viewv-elseclass="message-bubble my-bubble"><textclass="message-text">{{ message.content }}</text></view><viewclass="message-time my-time"> {{ formatTime(message.time) }}</view></view></view></view></view></scroll-view><!-- 提示词区域 - 横向滚动,固定在输入框上方 --><viewclass="tip-wrapper"><scroll-viewscroll-xclass="tip-scroll":show-scrollbar="false"><viewclass="tip-list"><viewclass="tip-item" @click="sendTip('帮我写一份工作周报')">帮我写一份工作周报</view><viewclass="tip-item" @click="sendTip('周末去哪里玩比较好')">周末去哪里玩比较好</view><viewclass="tip-item" @click="sendTip('推荐一部好看的电影')">推荐一部好看的电影</view><viewclass="tip-item" @click="sendTip('怎么做红烧肉')">怎么做红烧肉</view><viewclass="tip-item" @click="sendTip('给我讲个笑话')">给我讲个笑话</view><viewclass="tip-item" @click="sendTip('如何提高工作效率')">如何提高工作效率</view><viewclass="tip-item" @click="sendTip('今天天气怎么样')">今天天气怎么样</view><viewclass="tip-item" @click="sendTip('学习英语有什么技巧')">学习英语有什么技巧</view><viewclass="tip-item" @click="sendTip('推荐几本好书')">推荐几本好书</view><viewclass="tip-item" @click="sendTip('怎么缓解压力')">怎么缓解压力</view></view></scroll-view></view><!-- 输入区域 - 风格 --><viewclass="input-area"><!-- 录音状态显示 --><viewclass="recording-mask"v-if="isRecording" @touchend="stopRecord" @touchcancel="cancelRecord"><viewclass="recording-content"><viewclass="recording-animation"><viewclass="recording-wave"></view><viewclass="recording-wave"></view><viewclass="recording-wave"></view></view><textclass="recording-text">正在录音 {{ recordingTime }}s</text><textclass="recording-tip">松开发送,上滑取消</text></view></view><viewclass="input-container"><!-- 功能按钮区域 - 纯图片 --><viewclass="function-buttons"><viewclass="func-btn" @click="toggleMorePanel"><imagesrc="/static/add-icon.png"class="func-img"mode="aspectFit"></image></view></view><!-- 输入框区域 --><viewclass="input-wrapper"><inputtype="text"v-model="inputMessage"placeholder="发消息或按住说话..."class="message-input" @confirm="sendMessage" @focus="hideAllPanels"placeholder-class="placeholder-style" /><viewclass="voice-input-btn" @touchstart="startRecord" @touchend="stopRecord" @touchcancel="cancelRecord"><imagesrc="/static/voice-input-icon.png"class="voice-input-img"mode="aspectFit"></image></view></view><!-- 发送按钮 --><buttonclass="send-btn":class="{ active: inputMessage.trim() }" @click="sendMessage":disabled="isSending"> {{ isSending ? '...' : '发送' }}</button></view><!-- 更多功能面板 - 纯图片图标 --><viewclass="more-panel"v-if="showMorePanel"><viewclass="more-grid"><viewclass="more-item" @click="uploadImage"><viewclass="more-icon-bg"><imagesrc="/static/photo-icon.png"class="more-img"mode="aspectFit"></image></view><textclass="more-text">相册</text></view><viewclass="more-item" @click="takePhoto"><viewclass="more-icon-bg"><imagesrc="/static/camera-icon.png"class="more-img"mode="aspectFit"></image></view><textclass="more-text">拍照</text></view></view></view></view></view></template><script>exportdefault { data() {return {inputMessage: '',showMorePanel: false,scrollTop: 0,loading: false,isSending: false,isRecording: false,recordingTime: 0,recordingTimer: null,tempFilePath: '',typingMessages: {},typingSpeed: 30, // 加快打字速度 statusBarHeight: 0,headerHeight: 0,messages: [{id: 'msg_001',sender: 'other',content: '你好,我是AI智能助手!有什么可以帮助你的吗?😊',time: newDate().getTime() - 3600000 * 2 }] } },computed: { today() {const date = newDate()return`${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日` }, showDateDivider() {returnthis.messages.some(msg => {const msgDate = newDate(msg.time)const today = newDate()return ( msgDate.getDate() === today.getDate() && msgDate.getMonth() === today.getMonth() && msgDate.getFullYear() === today.getFullYear() ) }) } },methods: { getSystemInfo() {const systemInfo = uni.getSystemInfoSync()const menuButtonInfo = uni.getMenuButtonBoundingClientRect()this.statusBarHeight = systemInfo.statusBarHeightthis.headerHeight = menuButtonInfo.bottom + 8 }, goBack() { uni.navigateBack() }, displayContent(message) {if (message.sender === 'me' || !message.id) {return message.content }if (this.typingMessages[message.id]) {const typingMsg = this.typingMessages[message.id]return typingMsg.content.substring(0, typingMsg.index) }return message.content }, sendTip(tipText) {this.inputMessage = tipTextthis.sendMessage() }, sendMessage() {if (!this.inputMessage.trim()) {return uni.showToast({title: '请输入消息',duration: 1000,icon: 'none' }) }this.isSending = trueconst newMessage = {id: 'msg_' + Date.now(),sender: 'me',content: this.inputMessage,time: newDate().getTime() }this.messages.push(newMessage)const userMessage = this.inputMessagethis.inputMessage = ''this.hideAllPanels()this.$nextTick(() => {this.scrollToBottom(true) })const typingMessageId = 'msg_typing_' + Date.now()const typingMessage = {id: typingMessageId,sender: 'other',content: '',typing: true,time: newDate().getTime() }this.messages.push(typingMessage)this.scrollToBottom(true)this.getAIResponse(userMessage).then(aiResponse => {this.receiveReply(aiResponse, typingMessageId.replace('typing_', '')) }).catch(err => {console.error('AI请求失败:', err)this.messages = this.messages.filter(msg => !msg.typing)this.receiveReply('抱歉,我暂时无法处理您的请求,请稍后再试。') }).finally(() => {this.isSending = false }) }, getAIResponse(userMessage) {returnnewPromise((resolve, reject) => { uni.request({url: 'https://api.deepseek.com/chat/completions',method: 'POST',header: {'Authorization': 'Bearer your key','Content-Type': 'application/json' },data: {"model": "deepseek-chat","messages": [{"role": "user","content": userMessage }],"stream": false,"max_tokens": 2512,"temperature": 0.7 },success: (res) => {if (res.data && res.data.choices && res.data.choices[0] && res.data .choices[0].message) {const replyContent = res.data.choices[0].message.contentif (replyContent) { resolve(replyContent) } else { reject(newError('AI回复为空')) } } else { reject(newError('无效的API响应')) } },fail: (err) => { reject(err) } }) }) }, receiveReply(content, messageId) {this.messages = this.messages.filter(msg => !msg.typing)const replyMessage = {id: messageId || ('msg_' + Date.now()),sender: 'other',content: content,time: newDate().getTime() }this.messages.push(replyMessage)this.scrollToBottom(true)if (content && content.length > 0) {this.startTypingEffect(replyMessage.id, content) } }, startTypingEffect(messageId, fullContent) {this.$set(this.typingMessages, messageId, {content: fullContent,index: 0,timer: null })const typeNextChar = () => {const typingMsg = this.typingMessages[messageId]if (!typingMsg) returnif (typingMsg.index < typingMsg.content.length) { typingMsg.index++this.$forceUpdate()this.scrollToBottom(true) typingMsg.timer = setTimeout(typeNextChar, this.typingSpeed) } else {this.$delete(this.typingMessages, messageId) } } setTimeout(typeNextChar, 300) }, scrollToBottom(animate = false) { setTimeout(() => {const query = uni.createSelectorQuery().in(this) query.select('.chat-content').boundingClientRect() query.select('.chat-messages').boundingClientRect() query.exec(res => {if (res[0] && res[1]) {// 确保滚动到底部this.scrollTop = res[1].height - res[0].height + 100 } }) }, 100) }, loadMoreMessages() {if (this.loading) returnthis.loading = true uni.showLoading({title: '加载中...' }) setTimeout(() => {const oldMessages = [...this.messages]const newMessages = [{id: 'msg_' + (Date.now() - 86400000),sender: 'other',content: '欢迎使用!有什么问题随时问我~',time: Date.now() - 86400000 }]this.messages = [...newMessages, ...oldMessages]this.loading = false uni.hideLoading() }, 800) }, toggleMorePanel() {this.showMorePanel = !this.showMorePanel }, hideAllPanels() {this.showMorePanel = false }, formatTime(timestamp) {const date = newDate(timestamp)const hours = date.getHours().toString().padStart(2, '0')const minutes = date.getMinutes().toString().padStart(2, '0')return`${hours}:${minutes}` },// 相册功能 uploadImage() {this.hideAllPanels() uni.chooseImage({count: 1,sizeType: ['compressed'],sourceType: ['album'],success: (res) => {const tempFilePath = res.tempFilePaths[0] uni.showLoading({title: '发送中...' }) setTimeout(() => {const newMessage = {id: 'msg_' + Date.now(),sender: 'me',imageUrl: tempFilePath,content: '[图片]',time: newDate().getTime() }this.messages.push(newMessage)this.scrollToBottom(true) uni.hideLoading() uni.showToast({title: '发送成功',icon: 'success',duration: 1000 }) }, 500) },fail: (err) => {console.error('选择图片失败', err) uni.showToast({title: '选择图片失败',icon: 'none' }) } }) },// 拍照功能 takePhoto() {this.hideAllPanels() uni.chooseImage({count: 1,sizeType: ['compressed'],sourceType: ['camera'],success: (res) => {const tempFilePath = res.tempFilePaths[0] uni.showLoading({title: '发送中...' }) setTimeout(() => {const newMessage = {id: 'msg_' + Date.now(),sender: 'me',imageUrl: tempFilePath,content: '[拍照]',time: newDate().getTime() }this.messages.push(newMessage)this.scrollToBottom(true) uni.hideLoading() uni.showToast({title: '发送成功',icon: 'success',duration: 1000 }) }, 500) },fail: (err) => {console.error('拍照失败', err) uni.showToast({title: '拍照失败',icon: 'none' }) } }) },// 预览图片 previewImage(imageUrl) { uni.previewImage({urls: [imageUrl],current: imageUrl }) }, startRecord() { uni.getRecorderManager().onStart(() => {this.isRecording = truethis.recordingTime = 0this.recordingTimer = setInterval(() => {this.recordingTime++if (this.recordingTime >= 60) {this.stopRecord() } }, 1000) }) uni.getRecorderManager().onStop((res) => {this.isRecording = falseif (this.recordingTimer) { clearInterval(this.recordingTimer)this.recordingTimer = null }if (this.recordingTime < 1) { uni.showToast({title: '录音时间太短',icon: 'none' })return }this.sendVoiceMessage(res.tempFilePath) }) uni.getRecorderManager().onError((err) => {console.error('录音错误', err)this.isRecording = falseif (this.recordingTimer) { clearInterval(this.recordingTimer)this.recordingTimer = null } uni.showToast({title: '录音失败',icon: 'none' }) }) uni.getRecorderManager().start({duration: 60000,format: 'mp3' }) }, stopRecord() {if (this.isRecording) { uni.getRecorderManager().stop() } }, cancelRecord() {if (this.isRecording) { uni.getRecorderManager().stop()this.isRecording = falseif (this.recordingTimer) { clearInterval(this.recordingTimer)this.recordingTimer = null } uni.showToast({title: '已取消录音',icon: 'none' }) } }, sendVoiceMessage(filePath) {const newMessage = {id: 'msg_' + Date.now(),sender: 'me',content: '[语音消息]',voiceUrl: filePath,duration: this.recordingTime,time: newDate().getTime() }this.messages.push(newMessage)this.scrollToBottom(true) }, playVoice(message) {if (!message.voiceUrl) returnconst innerAudioContext = uni.createInnerAudioContext() innerAudioContext.src = message.voiceUrl innerAudioContext.onError((err) => {console.error('语音播放错误', err) uni.showToast({title: '播放失败',icon: 'none' }) innerAudioContext.destroy() }) innerAudioContext.play() } }, onLoad() {this.getSystemInfo()this.scrollToBottom() }, beforeDestroy() {Object.values(this.typingMessages).forEach(msg => {if (msg.timer) { clearTimeout(msg.timer) } }) } }</script><stylelang="scss"scoped> .chat-container { height: 100vh; display: flex; flex-direction: column; background: #F1F1FE; background-attachment: fixed; background: linear-gradient(to bottom, transparent, #F1F1FE 300px), radial-gradient(20% 150px at 70% 230px, rgb(51 0 255 / 20%), transparent), radial-gradient(40% 180px at 80% 50px, rgb(166 161 243 / 30%), transparent), radial-gradient(50% 300px at 90% 100px, rgba(212, 230, 241, 0.8), transparent), radial-gradient(20% 150px at 0px 0px, rgba(162, 213, 239, 0.5), transparent), radial-gradient(30% 200px at 100px 50px, rgb(167 196 249 / 50%), transparent), #FFF0F5 } /* 顶部导航栏 - 风格,与胶囊按钮对齐 */ .chat-header { display: flex; align-items: center; justify-content: space-between; padding-left: 24rpx; padding-right: 24rpx; box-sizing: border-box; position: relative; z-index: 10; } .header-left { display: flex; align-items: center; justify-content: center; } .back-icon { font-size: 48rpx; color: #5e5ce0; font-weight: 400; } .header-center { flex: 1; display: flex; align-items: center; justify-content: center; } .bot-name { font-size: 36rpx; font-weight: 600; color: #000000; line-height: 1.4; margin-right: 20rpx; } .status-badge { display: flex; align-items: center; margin-top: 4rpx; } .status-dot { width: 12rpx; height: 12rpx; background-color: #4cd964; border-radius: 50%; margin-right: 8rpx; } .status-text { font-size: 22rpx; color: #8e8e93; } .header-right { width: 80rpx; height: 80rpx; display: flex; align-items: center; justify-content: center; } .more-icon { font-size: 44rpx; color: #5e5ce0; font-weight: 600; } /* 聊天内容区域 */ .chat-content { flex: 1; padding: 20rpx 24rpx; overflow: hidden; box-sizing: border-box; height: auto; } .chat-messages { display: flex; flex-direction: column; padding-bottom: 20rpx; } .time-divider { display: flex; justify-content: center; margin: 24rpx 0; } .time-divider text { background-color: rgba(0, 0, 0, 0.3); backdrop-filter: blur(10px); color: rgba(255, 255, 255, 0.9); font-size: 22rpx; padding: 6rpx 20rpx; border-radius: 20rpx; } .message-item { display: flex; margin-bottom: 24rpx; width: 100%; } .message-item.my-message { justify-content: flex-end; } .message-item.other-message { justify-content: flex-start; } .message-content { // max-width: 80%; display: flex; flex-direction: column; } /* AI消息特殊样式 - 宽度100% */ .ai-bubble { max-width: 100%; display: block; } .ai-message-text { display: block; width: 100%; word-wrap: break-word; word-break: break-all; white-space: pre-wrap; line-height: 1.6; } .message-content.my-content { align-items: flex-end; } .message-bubble { padding: 20rpx 24rpx; border-radius: 18rpx; position: relative; word-break: break-word; } .other-bubble { background-color: rgba(255, 255, 255, 0.95); backdrop-filter: blur(10px); border-top-left-radius: 8rpx; box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1); } .my-bubble { background: linear-gradient(135deg, #009688 0%, #03A9F4 100%); border-top-right-radius: 8rpx; box-shadow: 0 2rpx 8rpx rgba(94, 92, 224, 0.3); } .message-text { font-size: 28rpx; line-height: 1.5; color: #1e1e1e; } .my-bubble .message-text { color: #ffffff; } .message-time { font-size: 22rpx; color: rgba(255, 255, 255, 0.7); margin-top: 8rpx; margin-left: 12rpx; margin-right: 12rpx; } .my-time { text-align: right; } /* 图片消息样式 */ .message-image { max-width: 400rpx; max-height: 400rpx; border-radius: 16rpx; } .image-bubble { padding: 0rpx !important; background: #00000000 !important; box-shadow:none !important; } /* 语音消息样式 */ .voice-bubble { display: flex; align-items: center; min-width: 140rpx; padding: 16rpx 24rpx !important; } .voice-img { width: 36rpx; height: 36rpx; margin-right: 12rpx; } .voice-duration { font-size: 28rpx; color: #ffffff; } /* 提示词区域 - 固定在输入框上方 */ .tip-wrapper { background: transparent; padding: 16rpx 24rpx; border-top: 1rpx solid rgba(0, 0, 0, 0.05); } .tip-scroll { white-space: nowrap; width: 100%; } .tip-list { display: inline-flex; gap: 16rpx; } .tip-item { display: inline-block; padding: 16rpx 32rpx; background: rgba(255, 255, 255, 0.95); border-radius: 48rpx; font-size: 28rpx; color: #5e5ce0; font-weight: 500; box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08); transition: all 0.2s ease; white-space: nowrap; } .tip-item:active { transform: scale(0.96); background: rgba(94, 92, 224, 0.1); } /* 输入区域 - 风格 */ .input-area { padding-bottom: env(safe-area-inset-bottom); position: relative; z-index: 1000000; background-color: #fff; } .input-container { display: flex; align-items: center; padding: 16rpx 24rpx; gap: 16rpx; background-color: #fff; } .function-buttons { display: flex; gap: 16rpx; } .func-btn { width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; border-radius: 32rpx; background-color: #f5f5f5; transition: all 0.2s; } .func-img { width: 40rpx; height: 40rpx; } .func-btn:active { background-color: rgba(233, 236, 239, 0.9); transform: scale(0.95); } .input-wrapper { flex: 1; position: relative; display: flex; align-items: center; } .message-input { flex: 1; background-color: #f5f5f5; border-radius: 48rpx; padding: 20rpx 80rpx 20rpx 28rpx; font-size: 32rpx; height: 72rpx; line-height: 72rpx; box-sizing: border-box; vertical-align: middle; } .placeholder-style { color: #c6c6c8; font-size: 28rpx; line-height: 72rpx; vertical-align: middle; display: inline-block; transform: translateY(0); } .voice-input-btn { position: absolute; right: 12rpx; top: 50%; transform: translateY(-50%); width: 56rpx; height: 56rpx; display: flex; align-items: center; justify-content: center; border-radius: 28rpx; background-color: transparent; } .voice-input-img { width: 36rpx; height: 36rpx; } .voice-input-btn:active { background-color: rgba(233, 236, 239, 0.5); } .send-btn { width: 120rpx; margin: 0; background-color: #f5f5f5; color: #8e8e93; border-radius: 48rpx; height: 72rpx; line-height: 72rpx; font-size: 28rpx; padding: 0 24rpx; border: none; font-weight: 500; transition: all 0.2s; border: none !important; outline: none !important; } .send-btn.active { background: linear-gradient(135deg, #5e5ce0 0%, #7b7ae6 100%); color: white; } .send-btn[disabled] { opacity: 0.5; } /* 录音状态遮罩 */ .recording-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; z-index: 999; } .recording-content { display: flex; flex-direction: column; align-items: center; } .recording-animation { display: flex; align-items: center; justify-content: center; margin-bottom: 40rpx; height: 100rpx; } .recording-wave { width: 12rpx; height: 40rpx; background: linear-gradient(135deg, #5e5ce0 0%, #7b7ae6 100%); margin: 0 8rpx; border-radius: 12rpx; animation: wave 1s ease-in-out infinite; } .recording-wave:nth-child(1) { animation-delay: 0s; } .recording-wave:nth-child(2) { animation-delay: 0.2s; } .recording-wave:nth-child(3) { animation-delay: 0.4s; } @keyframes wave { 0%, 100% { height: 40rpx; } 50% { height: 80rpx; } } .recording-text { color: #ffffff; font-size: 32rpx; margin-bottom: 20rpx; } .recording-tip { color: rgba(255, 255, 255, 0.7); font-size: 26rpx; } /* 更多功能面板 */ .more-panel { position: absolute; bottom: 100%; left: 0; right: 0; background-color: #fff; border-radius: 24rpx 24rpx 0 0; animation: slideUp 0.3s ease; z-index: 999; } @keyframes slideUp { from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } .more-grid { display: flex; align-items: center; justify-content: flex-start; padding: 24rpx 32rpx; gap: 55rpx; } .more-item { display: flex; flex-direction: column; align-items: center; gap: 12rpx; } .more-icon-bg { width: 88rpx; height: 88rpx; display: flex; align-items: center; justify-content: center; background-color: #f5f5f5; border-radius: 44rpx; transition: all 0.2s; } .more-img { width: 48rpx; height: 48rpx; } .more-item:active .more-icon-bg { transform: scale(0.95); background-color: rgba(233, 236, 239, 0.9); } .more-text { font-size: 24rpx; color: #737373; font-weight: 500; } /* 打字指示器 */ .typing-indicator { display: flex; padding: 8rpx 0 0 0; } .typing-indicator .dot { width: 8rpx; height: 8rpx; background-color: #5e5ce0; border-radius: 50%; margin-right: 8rpx; animation: typingAnimation 1.4s infinite ease-in-out; } .typing-indicator .dot:nth-child(1) { animation-delay: 0s; } .typing-indicator .dot:nth-child(2) { animation-delay: 0.2s; } .typing-indicator .dot:nth-child(3) { animation-delay: 0.4s; margin-right: 0; } @keyframes typingAnimation { 0%, 60%, 100% { transform: translateY(0); opacity: 0.6; } 30% { transform: translateY(-10rpx); opacity: 1; } }</style>
夜雨聆风