调整日记评论面板跟随键盘并保留输入草稿

This commit is contained in:
2026-10-11 09:32:45 +08:00
parent 1bafb01741
commit 45c81aeaac
4 changed files with 120 additions and 19 deletions
+44 -10
View File
@@ -12,6 +12,12 @@ Page(withDataCache({
hasMoreComments: true, hasMoreComments: true,
showCommentInput: false, showCommentInput: false,
commentText: '', commentText: '',
commentInitialText: '',
commentLength: 0,
commentFocus: false,
commentKeyboardHeight: 0,
commentEditorHeight: 56,
commentSending: false,
replyTo: null, replyTo: null,
coverSwiperIndex: 0, coverSwiperIndex: 0,
previewImages: [], previewImages: [],
@@ -119,28 +125,49 @@ Page(withDataCache({
onPreviewSwiperChange: function(e) { this.setData({ previewIndex: e.detail.current }); }, onPreviewSwiperChange: function(e) { this.setData({ previewIndex: e.detail.current }); },
onCommentTap: function() { onCommentTap: function() {
this.setData({ showCommentInput: true, replyTo: null, commentText: '' }); this.openComment(null);
}, },
onReplyTap: function(e) { onReplyTap: function(e) {
var item = e.currentTarget.dataset.item; var item = e.currentTarget.dataset.item;
this.setData({ this.openComment({ id: item.id, user_id: item.user_id, nickname: item.nickname });
showCommentInput: true, },
replyTo: { id: item.id, user_id: item.user_id, nickname: item.nickname }, openComment: function(replyTo) {
commentText: '' var sameTarget = (!replyTo && !this.data.replyTo) ||
}); (replyTo && this.data.replyTo && replyTo.id === this.data.replyTo.id);
var text = sameTarget ? this.data.commentText : '';
this.setData({ showCommentInput: true, replyTo: replyTo, commentText: text,
commentInitialText: text, commentLength: text.length, commentFocus: true,
commentKeyboardHeight: 0, commentEditorHeight: 56 });
}, },
onCommentInput: function(e) { onCommentInput: function(e) {
this.setData({ commentText: e.detail.value }); // 不把正文反复写回原生输入框,避免中文输入时光标跳动。
this.data.commentText = e.detail.value || '';
this.setData({ commentLength: this.data.commentText.length });
},
onCommentKeyboardHeight: function(e) {
var height = Math.max(0, Number(e.detail.height) || 0);
if (this.data.showCommentInput) this.setData({ commentKeyboardHeight: height });
},
onCommentLineChange: function(e) {
this.setData({ commentEditorHeight: Math.min(160, Math.max(56, Number(e.detail.height) || 56)) });
},
onCommentBlur: function() {
this.setData({ commentFocus: false });
}, },
cancelComment: function() { cancelComment: function() {
this.setData({ showCommentInput: false, replyTo: null, commentText: '' }); if (this.data.commentSending) return;
this.setData({ showCommentInput: false, commentFocus: false, commentKeyboardHeight: 0 });
wx.hideKeyboard();
},
onHide: function() {
this.setData({ showCommentInput: false, commentFocus: false, commentKeyboardHeight: 0 });
}, },
submitComment: async function() { submitComment: async function() {
if (this.data.commentSending) return;
var text = this.data.commentText.trim(); var text = this.data.commentText.trim();
if (!text) { wx.showToast({ title: '请输入评论内容', icon: 'none' }); return; } if (!text) { wx.showToast({ title: '请输入评论内容', icon: 'none' }); return; }
this.setData({ commentSending: true });
try { try {
var params = { diary_id: this.data.diaryId, content: text }; var params = { diary_id: this.data.diaryId, content: text };
if (this.data.replyTo) { if (this.data.replyTo) {
@@ -166,12 +193,19 @@ Page(withDataCache({
showCommentInput: false, showCommentInput: false,
replyTo: null, replyTo: null,
commentText: '', commentText: '',
commentInitialText: '',
commentLength: 0,
commentFocus: false,
commentKeyboardHeight: 0,
'diary.comment_count': (this.data.diary.comment_count || 0) + 1 'diary.comment_count': (this.data.diary.comment_count || 0) + 1
}); });
wx.hideKeyboard();
wx.showToast({ title: '评论成功', icon: 'success' }); wx.showToast({ title: '评论成功', icon: 'success' });
} }
} catch (e) { } catch (e) {
wx.showToast({ title: e.error || '评论失败', icon: 'none' }); wx.showToast({ title: e.error || '评论失败', icon: 'none' });
} finally {
this.setData({ commentSending: false });
} }
}, },
+7 -8
View File
@@ -71,15 +71,14 @@
</view> </view>
</view> </view>
<!-- 评论输入弹窗 --> <!-- 评论面板自行跟随键盘,禁用原生页面上推,避免双重位移。 -->
<view class="modal-mask" wx:if="{{showCommentInput}}" catchtap="cancelComment"> <view class="comment-compose-mask" wx:if="{{showCommentInput}}" catchtap="cancelComment" catchtouchmove="noop">
<view class="modal-box" catchtap="noop"> <view class="comment-compose {{commentKeyboardHeight > 0 ? 'comment-compose--keyboard' : ''}}" style="bottom:{{commentKeyboardHeight}}px;" catchtap="noop">
<view class="modal-top"> <view class="comment-compose-heading"><text>{{replyTo ? '回复 @' + replyTo.nickname : '写下你的留言'}}</text><text class="comment-compose-close" catchtap="cancelComment">收起</text></view>
<text class="modal-cancel" catchtap="cancelComment">取消</text> <view class="comment-compose-field">
<text class="modal-title">{{replyTo ? '回复 @' + replyTo.nickname : '发表评论'}}</text> <textarea placeholder-class="handwritten-placeholder" class="comment-compose-input" style="height:{{commentEditorHeight}}px;" placeholder="说点什么,让 TA 知道你的心情…" value="{{commentInitialText}}" bindinput="onCommentInput" bindlinechange="onCommentLineChange" bindkeyboardheightchange="onCommentKeyboardHeight" bindblur="onCommentBlur" focus="{{commentFocus}}" fixed="{{true}}" adjust-position="{{false}}" hold-keyboard="{{true}}" show-confirm-bar="{{false}}" cursor-spacing="0" maxlength="500" disabled="{{commentSending}}" />
<text class="modal-send" catchtap="submitComment">发送</text>
</view> </view>
<textarea placeholder-class="handwritten-placeholder" class="modal-input" placeholder="说点什么..." value="{{commentText}}" bindinput="onCommentInput" auto-focus maxlength="500" /> <view class="comment-compose-toolbar"><text class="comment-compose-count">{{commentLength}}/500</text><button class="comment-compose-send" catchtap="submitComment" disabled="{{commentLength === 0 || commentSending}}" loading="{{commentSending}}" hover-class="none">发送</button></view>
</view> </view>
</view> </view>
+13 -1
View File
@@ -12,7 +12,19 @@
.comment-box { margin-top:38rpx; }.comment-head { padding-bottom:20rpx; border-bottom:1rpx solid #E9DDD0; }.comment-head-text { color:#352E2A; font-size:28rpx; font-weight:600; }.comment-empty { padding:44rpx 8rpx; text-align:center; font-size:24rpx; line-height:1.7; color:#85776D; } .comment-box { margin-top:38rpx; }.comment-head { padding-bottom:20rpx; border-bottom:1rpx solid #E9DDD0; }.comment-head-text { color:#352E2A; font-size:28rpx; font-weight:600; }.comment-empty { padding:44rpx 8rpx; text-align:center; font-size:24rpx; line-height:1.7; color:#85776D; }
.comment-item { display:flex; align-items:flex-start; gap:14rpx; padding:24rpx 0; border-bottom:1rpx solid #E9DDD0; }.comment-item:last-child { border:0; }.comment-avatar { width:48rpx; height:48rpx; border-radius:50%; flex-shrink:0; }.comment-body { flex:1; min-width:0; }.comment-top { display:flex; justify-content:space-between; gap:12rpx; flex-wrap:wrap; margin-bottom:12rpx; }.comment-name { font-size:24rpx; color:#352E2A; font-weight:500; }.comment-date { font-size:20rpx; color:#A39386; }.comment-content { display:block; font-size:26rpx; line-height:1.65; color:#65594F; overflow-wrap:break-word; }.comment-at { color:#B74768; } .comment-item { display:flex; align-items:flex-start; gap:14rpx; padding:24rpx 0; border-bottom:1rpx solid #E9DDD0; }.comment-item:last-child { border:0; }.comment-avatar { width:48rpx; height:48rpx; border-radius:50%; flex-shrink:0; }.comment-body { flex:1; min-width:0; }.comment-top { display:flex; justify-content:space-between; gap:12rpx; flex-wrap:wrap; margin-bottom:12rpx; }.comment-name { font-size:24rpx; color:#352E2A; font-weight:500; }.comment-date { font-size:20rpx; color:#A39386; }.comment-content { display:block; font-size:26rpx; line-height:1.65; color:#65594F; overflow-wrap:break-word; }.comment-at { color:#B74768; }
.bottom-bar { position:fixed; left:0; right:0; bottom:0; z-index:20; background:#FFFDF8; border-top:1rpx solid #E9DDD0; padding:16rpx 44rpx calc(16rpx + env(safe-area-inset-bottom)); height:calc(100rpx + env(safe-area-inset-bottom)); box-sizing:border-box; }.bottom-input { display:flex; align-items:center; gap:14rpx; height:66rpx; border:1rpx solid #E9DDD0; background:#FAF5EE; padding:0 20rpx; border-radius:16rpx; }.bottom-input-icon { width:30rpx; height:30rpx; }.bottom-input-text { color:#85776D; font-size:25rpx; } .bottom-bar { position:fixed; left:0; right:0; bottom:0; z-index:20; background:#FFFDF8; border-top:1rpx solid #E9DDD0; padding:16rpx 44rpx calc(16rpx + env(safe-area-inset-bottom)); height:calc(100rpx + env(safe-area-inset-bottom)); box-sizing:border-box; }.bottom-input { display:flex; align-items:center; gap:14rpx; height:66rpx; border:1rpx solid #E9DDD0; background:#FAF5EE; padding:0 20rpx; border-radius:16rpx; }.bottom-input-icon { width:30rpx; height:30rpx; }.bottom-input-text { color:#85776D; font-size:25rpx; }
.modal-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:1200; display:flex; align-items:flex-end; }.modal-box { width:100%; border-radius:24rpx 24rpx 0 0!important; padding:28rpx 32rpx calc(28rpx + env(safe-area-inset-bottom)); }.modal-top { display:flex; align-items:center; justify-content:space-between; gap:16rpx; margin-bottom:24rpx; }.modal-cancel,.modal-send { padding:12rpx 0; font-size:26rpx; color:#85776D; }.modal-send { color:#B74768; }.modal-title { font-size:28rpx; font-weight:500; color:#352E2A; }.modal-input { width:100%; height:200rpx; background:#FAF5EE; border:1rpx solid #E9DDD0; border-radius:16rpx; padding:20rpx; font-size:27rpx; box-sizing:border-box; } .comment-compose-mask { position:fixed; inset:0; z-index:1200; background:rgba(53,46,42,.24); }
.comment-compose { position:fixed; left:0; right:0; padding:22rpx 28rpx calc(20rpx + env(safe-area-inset-bottom)); box-sizing:border-box; border-radius:28rpx 28rpx 0 0; background:#FFFDF8; }
.comment-compose--keyboard { padding-bottom:16rpx; }
.comment-compose-heading { display:flex; align-items:center; justify-content:space-between; gap:20rpx; margin-bottom:16rpx; font-size:23rpx; line-height:1.5; color:#85776D; }
.comment-compose-heading > text:first-child { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; }
.comment-compose-close { flex-shrink:0; padding:8rpx 4rpx; color:#B74768; }
.comment-compose-field { background:#FFF0F5; border-radius:22rpx; padding:20rpx 24rpx; }
.comment-compose-input { display:block; width:100%; font-size:28rpx; line-height:1.6; color:#352E2A; }
.comment-compose-toolbar { display:flex; align-items:center; justify-content:space-between; margin-top:14rpx; }
.comment-compose-count { font-size:21rpx; color:#A39386; }
.comment-compose-send { margin:0; min-width:124rpx; height:64rpx; padding:0 28rpx; border-radius:32rpx; background:#F47FA5; color:#FFF; font-size:25rpx; line-height:64rpx; }
.comment-compose-send::after { border:0; }
.comment-compose-send[disabled] { background:#F4E6EB; color:#A39386; }
.preview-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:9999; background:rgba(0,0,0,.95); }.preview-swiper { width:100%; height:100%; }.preview-slide { position:relative; }.preview-img { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; display:block; }.preview-num { position:fixed; bottom:calc(44rpx + env(safe-area-inset-bottom)); left:0; right:0; text-align:center; color:#FFFDF8; font-size:25rpx; }.preview-close { position:fixed; top:var(--nav-height,64px); right:24rpx; width:72rpx; height:72rpx; display:flex; align-items:center; justify-content:center; }.preview-close-icon { width:34rpx; height:34rpx; filter:brightness(0) invert(1); } .preview-mask { position:fixed; top:0; left:0; right:0; bottom:0; z-index:9999; background:rgba(0,0,0,.95); }.preview-swiper { width:100%; height:100%; }.preview-slide { position:relative; }.preview-img { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; display:block; }.preview-num { position:fixed; bottom:calc(44rpx + env(safe-area-inset-bottom)); left:0; right:0; text-align:center; color:#FFFDF8; font-size:25rpx; }.preview-close { position:fixed; top:var(--nav-height,64px); right:24rpx; width:72rpx; height:72rpx; display:flex; align-items:center; justify-content:center; }.preview-close-icon { width:34rpx; height:34rpx; filter:brightness(0) invert(1); }
@media (max-width:350px) { .read-page { padding-left:32rpx; padding-right:32rpx; }.bottom-bar { padding-left:32rpx; padding-right:32rpx; } } @media (max-width:350px) { .read-page { padding-left:32rpx; padding-right:32rpx; }.bottom-bar { padding-left:32rpx; padding-right:32rpx; } }
+56
View File
@@ -0,0 +1,56 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const createLoader = require('./load-module.cjs');
function setup(api = {}) {
let definition; const patches = []; let hidden = 0;
createLoader({ Page: value => definition = value, wx: { hideKeyboard: () => hidden++, showToast() {} }, console, Date, Promise }, {
'../../utils/page-cache': { withDataCache: value => value },
'../../utils/layout': { getLayout: () => ({}) },
'../../utils/request': { api, getImageUrl: value => value }
})(path.join(__dirname, '../pages/diary_detail/diary_detail.js'));
const page = { ...definition, data: { ...definition.data, diaryId: 1, diary: { comment_count: 0 } },
setData(patch) { patches.push(patch); for (const [key, value] of Object.entries(patch)) {
if (key === 'diary.comment_count') this.data.diary.comment_count = value;
else this.data[key] = value;
} } };
return { page, patches, hidden: () => hidden };
}
test('composer follows keyboard without rewriting text, and caps long input height', () => {
const { page, patches } = setup(); page.onCommentTap(); patches.length = 0;
page.onCommentInput({ detail: { value: '字'.repeat(300) } });
page.onCommentKeyboardHeight({ detail: { height: 360 } });
page.onCommentLineChange({ detail: { height: 400 } });
assert.equal(page.data.commentKeyboardHeight, 360);
assert.equal(page.data.commentEditorHeight, 160);
assert.equal(page.data.commentText.length, 300);
assert.ok(patches.every(p => !('commentInitialText' in p) && !('commentText' in p)));
page.onCommentKeyboardHeight({ detail: { height: 0 } });
assert.equal(page.data.commentKeyboardHeight, 0);
});
test('closing and reopening preserves draft while changing reply target resets it', () => {
const { page } = setup(); page.onCommentTap(); page.onCommentInput({ detail: { value: '未发送' } });
page.cancelComment(); page.onCommentTap(); assert.equal(page.data.commentInitialText, '未发送');
page.onReplyTap({ currentTarget: { dataset: { item: { id: 2, user_id: 3, nickname: 'TA' } } } });
assert.equal(page.data.commentText, ''); assert.equal(page.data.replyTo.id, 2);
page.onHide(); assert.equal(page.data.commentKeyboardHeight, 0); assert.equal(page.data.commentFocus, false);
});
test('failed send preserves draft and allows retry; concurrent send is ignored', async () => {
let release; let calls = 0;
const { page } = setup({ diaryCommentAdd: () => { calls++; return new Promise((resolve, reject) => release = reject); } });
page.onCommentTap(); page.onCommentInput({ detail: { value: '保留这句话' } });
const pending = page.submitComment(); await page.submitComment(); assert.equal(calls, 1);
release({ error: '网络错误' }); await pending;
assert.equal(page.data.commentSending, false); assert.equal(page.data.commentText, '保留这句话');
assert.equal(page.data.showCommentInput, true);
});
test('successful reply sends reply metadata and clears composer', async () => {
let params;
const { page, hidden } = setup({ diaryCommentAdd: async value => { params = value; return { success: true, comment: { id: 5 } }; } });
page.onReplyTap({ currentTarget: { dataset: { item: { id: 2, user_id: 3, nickname: 'TA' } } } });
page.onCommentInput({ detail: { value: '回复内容' } }); await page.submitComment();
assert.equal(params.parent_id, 2); assert.equal(params.reply_to_user_id, 3);
assert.equal(page.data.commentText, ''); assert.equal(page.data.commentKeyboardHeight, 0);
assert.equal(page.data.showCommentInput, false); assert.equal(page.data.diary.comment_count, 1);
assert.equal(hidden(), 1);
});