个人利用workbuddy开发小程序,在苹果、安卓、鸿蒙三端真机调试,聊天页面无法同时实现以下功能:
需求 1:进入聊天页面,输入框区块在底部完整显示且不遮挡最后一条信息
需求 2:最新消息实时刷新显示在输入框上面
需求 3:键盘弹起后,键盘与输入框紧密贴合不重叠
需求 4:点击发送按钮后键盘常驻
需求 5:输入框内容多时换行
目前代码如下
chat.js
var api = require('../../../utils/api');
var app = getApp();
var POLL_INTERVAL = 5000;
var pollTimer = null;
var DEFAULT_AVATAR = '/images/default-avatar.png';
Page({
data: {
convId: '',
toUserId: '',
otherAvatar: DEFAULT_AVATAR,
otherName: '',
myAvatar: DEFAULT_AVATAR,
messages: [],
loading: true,
inputText: '',
inputFocus: false,
scrollTop: 0,
/* === 键盘适配变量 === */
windowHeight: 0,
safeAreaBottom: 0,
keyboardHeight: 0, /* container padding-bottom = keyboardHeight,推动输入栏到键盘上方 */
scrollPadding: 140, /* scroll-view 内部底部留白,防止最后消息被输入栏遮挡 */
inputBarPadBottom: 0, /* 输入栏 padding-bottom,无键盘时含 safe-area */
inputBarHeight: 0, /* 输入栏实际高度(onReady 测量) */
},
/* 内部状态(不进 data) */
_lastKBHeight: -1,
_lastKBTime: 0,
_pendingFocus: false,
_kbFallbackTimer: null,
onLoad: function (options) {
var self = this;
/* 获取屏幕信息 */
var sys = wx.getWindowInfo();
var safeBottom = sys.screenHeight - sys.safeArea.bottom;
var wh = sys.windowHeight;
console.log('[chat] windowHeight=' + wh + ' safeBottom=' + safeBottom);
self.setData({
windowHeight: wh,
safeAreaBottom: safeBottom,
scrollPadding: 140,
inputBarPadBottom: safeBottom,
});
/* 注册全局键盘高度监听(兜底,组件事件可能不触发) */
wx.onKeyboardHeightChange(self._onGlobalKBChange.bind(self));
var convId = options.convId || '';
var toUserId = options.toUserId || '';
var userInfo = app.globalData.userInfo || {};
var myAvatar = self._safeAvatar(userInfo.avatar);
self.setData({
convId: convId,
toUserId: toUserId,
myAvatar: myAvatar,
});
if (convId) {
self.loadConvInfo(convId);
self.loadMessages();
self.startPoll();
} else if (toUserId) {
self.createOrGetConv(toUserId);
}
},
onReady: function () {
var self = this;
/* 测量输入栏实际高度 */
setTimeout(function () {
wx.createSelectorQuery().select('.input-bar').boundingClientRect(function (rect) {
if (rect && rect.height > 0) {
console.log('[chat] inputBar measured height=' + rect.height);
var pad = rect.height + self.data.safeAreaBottom + 10;
self.setData({
inputBarHeight: rect.height,
scrollPadding: Math.max(pad, 140),
});
}
}).exec();
}, 800);
},
onUnload: function () {
this.stopPoll();
this._clearFallback();
wx.offKeyboardHeightChange(this._onGlobalKBChange);
},
onHide: function () {
this.stopPoll();
this._clearFallback();
this._lastKBHeight = -1;
this._lastKBTime = 0;
},
onShow: function () {
if (this.data.convId) {
this.startPoll();
}
},
_clearFallback: function () {
if (this._kbFallbackTimer) {
clearTimeout(this._kbFallbackTimer);
this._kbFallbackTimer = null;
}
this._pendingFocus = false;
},
_safeAvatar: function (avatar) {
if (!avatar) return DEFAULT_AVATAR;
if (avatar.indexOf('data:image') === 0 || avatar.indexOf('data:') === 0) return DEFAULT_AVATAR;
return avatar;
},
/* ========== 键盘高度处理(核心适配逻辑) ========== */
onKeyboardHeightChange: function (e) {
var h = (e.detail && typeof e.detail.height === 'number') ? e.detail.height : -1;
if (h < 0 && typeof e.height === 'number') h = e.height;
this._handleKBChange(h, 'component');
},
_onGlobalKBChange: function (res) {
var h = (typeof res.height === 'number') ? res.height : -1;
if (h < 0 && res.detail && typeof res.detail.height === 'number') h = res.detail.height;
this._handleKBChange(h, 'global');
},
_handleKBChange: function (h, source) {
/* 收到真实事件,取消 fallback */
this._clearFallback();
if (typeof h !== 'number') h = 0;
/* 时间窗口去重:同一高度 300ms 内只处理一次 */
var now = Date.now();
if (h === this._lastKBHeight && now - this._lastKBTime < 300) {
return;
}
this._lastKBHeight = h;
this._lastKBTime = now;
console.log('[chat] KB change (src=' + source + '): height=' + h);
var self = this;
var barHeight = self.data.inputBarHeight || 100;
if (h > 0) {
/* 键盘弹出:container padding-bottom = keyboardHeight,输入栏自然位于键盘上方 */
self.setData({
keyboardHeight: h,
scrollPadding: Math.max(barHeight + 20, 100),
inputBarPadBottom: 0, /* 键盘已覆盖安全区,无需额外 padding */
});
} else {
/* 键盘收起:恢复原始状态 */
var pad = barHeight + self.data.safeAreaBottom + 10;
self.setData({
keyboardHeight: 0,
scrollPadding: Math.max(pad, 140),
inputBarPadBottom: self.data.safeAreaBottom,
});
}
/* 延迟滚动到底部,等布局稳定 */
setTimeout(function () {
self.scrollToBottom();
}, 300);
},
/* ========== 焦点事件 ========== */
onFocus: function (e) {
console.log('[chat] focus');
var self = this;
/* 启动 fallback 计时器:400ms 内无键盘事件则估算 */
self._pendingFocus = true;
self._kbFallbackTimer = setTimeout(function () {
if (self._pendingFocus) {
self._pendingFocus = false;
var estimate = Math.round(self.data.windowHeight * 0.42);
console.warn('[chat] KB height fallback estimate: ' + estimate + 'px (event did not fire)');
self._handleKBChange(estimate, 'fallback');
}
}, 400);
/* 延迟滚动到底部 */
setTimeout(function () {
self.scrollToBottom();
}, 350);
},
onBlur: function () {
console.log('[chat] blur');
this._clearFallback();
},
/* ========== 发送消息(含键盘常驻逻辑) ========== */
sendMessage: function () {
var self = this;
var content = self.data.inputText.trim();
if (!content || !self.data.convId) return;
console.log('[chat] sendMessage:', content);
var tempId = 'temp_' + Date.now();
var tempMsg = {
id: tempId,
senderId: 'me',
content: content,
isMine: true,
createdAt: new Date().toISOString(),
};
var messages = self.data.messages.concat([tempMsg]);
self.setData({ messages: messages, inputText: '' });
self.scrollToBottom();
/* 强制 refocus:false→true 确保键盘常驻 */
self.setData({ inputFocus: false });
setTimeout(function () {
self.setData({ inputFocus: true });
}, 200);
api.request({
url: '/messages/message',
method: 'POST',
data: { conversationId: self.data.convId, content: content },
showLoading: false
}).then(function (res) {
var data = res.data || {};
var msgs = self.data.messages;
for (var i = 0; i < msgs.length; i++) {
if (msgs[i].id === tempId) {
msgs[i].id = data.id;
msgs[i].createdAt = data.createdAt;
break;
}
}
msgs = self.formatMessages(msgs);
self.setData({ messages: msgs });
}).catch(function () { });
},
onConfirm: function () {
this.sendMessage();
},
onInput: function (e) {
this.setData({ inputText: e.detail.value });
},
onLineChange: function (e) {
this.scrollToBottom();
},
/* ========== 消息操作 ========== */
createOrGetConv: function (toUserId, transactionId) {
var self = this;
api.request({
url: '/messages/conversations',
method: 'POST',
data: { toUserId: toUserId, transactionId: transactionId || null },
showLoading: false
}).then(function (res) {
var data = res.data || {};
var conv = data;
var otherAvatar = self._safeAvatar(conv.otherUser && conv.otherUser.avatar);
self.setData({
convId: conv.id,
otherAvatar: otherAvatar,
otherName: (conv.otherUser && conv.otherUser.nickname) || '',
});
wx.setNavigationBarTitle({ title: self.data.otherName || '聊天' });
self.loadMessages();
self.markRead(conv.id);
self.startPoll();
});
},
loadConvInfo: function (convId) {
var self = this;
api.request({
url: '/messages/conversations',
method: 'GET',
data: { page: 1, limit: 100 },
showLoading: false
}).then(function (res) {
var items = (res.data && res.data.items) || [];
for (var i = 0; i < items.length; i++) {
if (items[i].id === convId) {
var conv = items[i];
var otherAvatar = self._safeAvatar(conv.otherUser && conv.otherUser.avatar);
self.setData({
otherAvatar: otherAvatar,
otherName: (conv.otherUser && conv.otherUser.nickname) || '',
});
wx.setNavigationBarTitle({ title: self.data.otherName || '聊天' });
self.markRead(convId);
break;
}
}
});
},
loadMessages: function () {
var self = this;
self.setData({ loading: true });
api.request({
url: '/messages/messages/' + self.data.convId,
method: 'GET',
data: { page: 1, limit: 100 },
showLoading: false
}).then(function (res) {
var items = (res.data && res.data.items) || [];
items = self.formatMessages(items);
self.setData({ messages: items, loading: false });
self.scrollToBottom();
}).catch(function () {
self.setData({ loading: false });
});
},
markRead: function (convId) {
api.request({
url: '/messages/messages/read/' + convId,
method: 'PUT',
data: {},
showLoading: false
});
},
/* ========== 滚动控制 ========== */
scrollToBottom: function () {
this.setData({ scrollTop: 99999 });
},
/* ========== 轮询新消息 ========== */
startPoll: function () {
this.stopPoll();
var self = this;
pollTimer = setInterval(function () {
if (!self.data.convId) return;
api.request({
url: '/messages/messages/' + self.data.convId,
method: 'GET',
data: { page: 1, limit: 100 },
showLoading: false
}).then(function (res) {
var items = (res.data && res.data.items) || [];
items = self.formatMessages(items);
var oldLast = self.data.messages.length > 0 ? self.data.messages[self.data.messages.length - 1].id : '';
var newLast = items.length > 0 ? items[items.length - 1].id : '';
if (items.length !== self.data.messages.length || oldLast !== newLast) {
self.setData({ messages: items });
self.markRead(self.data.convId);
self.scrollToBottom();
}
});
}, POLL_INTERVAL);
},
stopPoll: function () {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
},
formatMessages: function (messages) {
if (!messages || messages.length === 0) return [];
var userId = (app.globalData.userInfo && app.globalData.userInfo.id) || '';
var prevTime = null;
return messages.map(function (msg) {
var isMine = (typeof msg.isMine === 'boolean') ? msg.isMine : (msg.senderId === userId);
var showTime = false;
var timeText = '';
if (msg.createdAt) {
var d = new Date(msg.createdAt);
var key = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate();
if (key !== prevTime) {
showTime = true;
timeText = formatChatTime(d);
prevTime = key;
}
}
return {
id: msg.id,
senderId: msg.senderId,
content: msg.content,
isMine: isMine,
showTime: showTime,
timeText: timeText,
createdAt: msg.createdAt,
};
});
},
});
function formatChatTime(d) {
var now = new Date();
var today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
var target = new Date(d.getFullYear(), d.getMonth(), d.getDate());
if (target.getTime() === today.getTime()) {
return padZero(d.getHours()) + ':' + padZero(d.getMinutes());
}
var yesterday = new Date(today.getTime() - 86400000);
if (target.getTime() === yesterday.getTime()) {
return '\u6628\u5929 ' + padZero(d.getHours()) + ':' + padZero(d.getMinutes());
}
var diff = today.getTime() - target.getTime();
if (diff < 7 * 86400000) {
var days = ['\u5468\u65e5', '\u5468\u4e00', '\u5468\u4e8c', '\u5468\u4e09', '\u5468\u56db', '\u5468\u4e94', '\u5468\u516d'];
return days[d.getDay()] + ' ' + padZero(d.getHours()) + ':' + padZero(d.getMinutes());
}
return (d.getMonth() + 1) + '-' + d.getDate() + ' ' + padZero(d.getHours()) + ':' + padZero(d.getMinutes());
}
function padZero(n) {
return n < 10 ? '0' + n : String(n);
}
chat.json
{
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-loading": "tdesign-miniprogram/loading/loading"
},
"navigationBarTitleText": "聊天"
}
chat.wxml
<view class="container" style="padding-bottom: {{keyboardHeight}}px;">
<scroll-view
class="msg-scroll"
scroll-y
scroll-top="{{scrollTop}}"
scroll-with-animation
enhanced="{{true}}"
bounces="{{false}}"
>
<t-loading wx:if="{{loading}}" theme="circular" size="40rpx" loading style="margin-top: 80rpx;" />
<block wx:for="{{messages}}" wx:key="id">
<view class="time-divider" wx:if="{{item.showTime}}">
<text class="time-text">{{item.timeText}}</text>
</view>
<view class="msg-row {{item.isMine ? 'msg-mine' : 'msg-other'}}">
<image class="msg-avatar avatar-left {{item.isMine ? 'avatar-hidden' : ''}}" src="{{otherAvatar}}" mode="aspectFill" />
<view class="bubble {{item.isMine ? 'bubble-mine' : 'bubble-other'}}">
<text class="bubble-text">{{item.content}}</text>
</view>
<image class="msg-avatar avatar-right {{!item.isMine ? 'avatar-hidden' : ''}}" src="{{myAvatar}}" mode="aspectFill" />
</view>
</block>
<!-- 底部留白:确保最后一条消息不被输入栏遮挡 -->
<view style="height: {{scrollPadding}}px;"></view>
</scroll-view>
<!-- 底部输入栏:在 flex 容器内,container padding-bottom 推动其位于键盘上方 -->
<view class="input-bar" style="padding-bottom: {{inputBarPadBottom}}px;">
<view class="input-wrap">
<textarea
class="msg-textarea"
placeholder="输入消息..."
value="{{inputText}}"
focus="{{inputFocus}}"
hold-keyboard="{{true}}"
adjust-position="{{false}}"
auto-height="{{true}}"
disable-default-padding="{{true}}"
show-confirm-bar="{{false}}"
confirm-type="send"
confirm-hold="{{true}}"
maxlength="-1"
bindinput="onInput"
bindconfirm="onConfirm"
bindkeyboardheightchange="onKeyboardHeightChange"
bindlinechange="onLineChange"
bindblur="onBlur"
bindfocus="onFocus"
/>
</view>
<view class="send-btn {{inputText ? 'send-active' : ''}}" catchtap="sendMessage">
<text class="send-text">发送</text>
</view>
</view>
</view>
chat.wxss
page {
height: 100vh;
overflow: hidden;
}
/* ---- 主容器:flex 纵向 + 动态 padding-bottom ---- */
.container {
display: flex;
flex-direction: column;
height: 100vh;
box-sizing: border-box; /* padding-bottom 计入 height,不撑高 */
background: #F5F5F5;
}
/* ---- 消息滚动区 ---- */
.msg-scroll {
flex: 1;
min-height: 0;
width: 100%;
box-sizing: border-box;
padding: 20rpx 0;
}
/* ---- 时间分隔 ---- */
.time-divider {
display: flex;
justify-content: center;
margin: 24rpx 0;
}
.time-text {
font-size: 22rpx;
color: #BBBBBB;
background: rgba(0, 0, 0, 0.04);
padding: 6rpx 20rpx;
border-radius: 16rpx;
}
/* ---- 消息行 ---- */
.msg-row {
display: flex;
align-items: flex-start;
gap: 16rpx;
margin-bottom: 24rpx;
padding: 0 24rpx;
}
.msg-mine {
justify-content: flex-end;
}
.msg-other {
justify-content: flex-start;
}
.msg-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #E0E0E0;
flex-shrink: 0;
}
.avatar-hidden {
display: none !important;
}
.bubble {
max-width: calc(100% - 72rpx - 16rpx);
padding: 20rpx 24rpx;
border-radius: 20rpx;
word-break: break-all;
}
.bubble-other {
background: #fff;
border-top-left-radius: 6rpx;
}
.bubble-mine {
background: #C0392B;
border-top-right-radius: 6rpx;
}
.bubble-text {
font-size: 28rpx;
line-height: 1.6;
}
.bubble-other .bubble-text {
color: #333;
}
.bubble-mine .bubble-text {
color: #fff;
}
/* ---- 底部输入栏(在 flex 容器内) ---- */
.input-bar {
display: flex;
align-items: flex-end;
gap: 16rpx;
padding: 16rpx 24rpx;
background: #fff;
border-top: 1rpx solid #F0F0F0;
flex-shrink: 0;
/* padding-bottom 通过 inline style 动态设置(safe-area / 键盘) */
}
.input-wrap {
flex: 1;
background: #F5F5F5;
border-radius: 36rpx;
padding: 16rpx 24rpx;
min-height: 80rpx;
max-height: 240rpx;
overflow: hidden;
box-sizing: border-box;
}
.msg-textarea {
width: 100%;
min-height: 48rpx;
max-height: 200rpx;
font-size: 28rpx;
line-height: 44rpx;
color: #333;
padding: 0;
box-sizing: border-box;
}
.send-btn {
flex-shrink: 0;
padding: 18rpx 40rpx;
background: #E0E0E0;
border-radius: 36rpx;
transition: all 0.2s;
margin-bottom: 4rpx;
}
.send-btn.send-active {
background: #C0392B;
}
.send-btn:active {
opacity: 0.8;
}
.send-text {
font-size: 28rpx;
color: #fff;
font-weight: 500;
}

谢谢你热心人
因社区直接粘贴代码 会丢掉代码,所以写了个代码片段。复制代码片段的代码到你本地试试。
https://developers.weixin.qq.com/s/eMgoWDmO8W9q