小程序实现对话框回复

This commit is contained in:
2025-10-08 01:37:39 +08:00
parent 178caaf28b
commit d043271757
5 changed files with 358 additions and 120 deletions

View File

@@ -1,2 +1,22 @@
[更新信息]
-2025.8.8 小程序实现页面跳转和首页的基本框架
-2025.10.8 小程序实现对话框回复 【2.10.5】
-2025.10.8 剩余需求列表
· 用户评论发布功能
· 评论回复功能
· 文评发布功能
· 用户[我的发布]查看功能
· 用户消息通知查看功能
· 设置中用户信息修改功能
· 修改oss地址取消过度使用
· 意见反馈功能
----------------------
· 帮助中心[暂删除]
· 每日签到[暂删除]
· 抽奖券功能[暂删除]
· 内推吗发布功能[暂删除]

View File

@@ -25,9 +25,14 @@ Component({
error: null as string | null,
totalCommentsCount: 0, // *** 新增:存储所有评论的总数 (包括回复) ***
cardReviewData: null as any | null,
showReplyInput: false,
currentReplyId: '',
replyContent: '',
showReplyInput: false, // 控制输入框是否显示
currentReplyId: '', // 当前正在回复的评论ID
currentReplyUsername: '', // 当前正在回复的用户名(用于 placeholder
replyContent: '', // 输入框中的内容
isSubmitDisabled: true,
@@ -50,6 +55,165 @@ Component({
},
methods: {
closeInput() {
if (this.data.showReplyInput) {
this.setData({
showReplyInput: false,
currentReplyId: null,
currentReplyUsername: '',
replyContent: '',
isSubmitDisabled: true,
keyboardHeight: 0
});
}
},
/**
* 页面滚动时,调用关闭函数
*/
onPageScroll() {
this.closeInput();
},
/**
* 点击“空白”区域(现在是整个 scroll-view调用关闭函数
*/
handleBlankTap() {
console.log("scroll-view 区域被点击,准备关闭输入框");
this.closeInput();
},
/**
* ✅ 核心修改:处理点击评论项的逻辑
*/
handleCommentTap(e: WechatMiniprogram.TouchEvent) {
const wasShowing = this.data.showReplyInput;
const tappedCommentId = e.currentTarget.dataset.item?.id;
// 步骤 1: 立刻关闭当前可能已打开的输入框
this.closeInput();
// 步骤 2: 使用延时来重新打开输入框
// 这是一个技巧,确保关闭操作完成后再执行打开操作,避免冲突
setTimeout(() => {
const commentData = e.currentTarget.dataset.item;
if (commentData) {
// 如果刚刚输入框是开着的,并且点的是同一个评论,那么我们其实是想关闭它,而不是重新打开
if (wasShowing && this.data.currentReplyId === tappedCommentId) {
return;
}
this.setData({
showReplyInput: true,
currentReplyId: commentData.id,
currentReplyUsername: commentData.username || '匿名用户',
replyContent: '',
isSubmitDisabled: true,
});
}
}, 50); // 50毫秒的延时足够
},
/**
* ✅ 核心修改:处理点击主评论入口的逻辑
*/
handleTapPrimaryCommentInput() {
// 同样,先关闭
this.closeInput();
// 延时后打开
setTimeout(() => {
this.setData({
showReplyInput: true,
currentReplyId: null,
currentReplyUsername: '',
});
}, 50);
},
/**
* ✅ 修改:提交时智能判断是“回复”还是“新评论”
*/
handleReplySubmit() {
if (this.data.isSubmitDisabled) return;
const { replyContent, currentReplyId, cardReviewData } = this.data;
const articleId = cardReviewData.article_id;
// 根据 currentReplyId 是否有值来判断
if (currentReplyId) {
// --- 这是回复 ---
console.log(`[提交回复] 内容: "${replyContent}" | 回复目标ID: ${currentReplyId} | 文章ID: ${articleId}`);
// 在此处对接您的【回复】API
// wx.request({ url: ..., data: { content: replyContent, parent_id: currentReplyId, article_id: articleId } })
} else {
// --- 这是新的一级评论 ---
console.log(`[提交新评论] 内容: "${replyContent}" | 文章ID: ${articleId}`);
// 在此处对接您的【发表新评论】API
// wx.request({ url: ..., data: { content: replyContent, article_id: articleId } })
}
wx.showToast({ title: '评论成功(模拟)', icon: 'success' });
// 提交后清空并隐藏输入框
this.setData({
showReplyInput: false,
currentReplyId: null,
currentReplyUsername: '',
replyContent: '',
isSubmitDisabled: true
});
// (可选) 成功后可以延时刷新评论列表
// setTimeout(() => {
// this.getCardDetail(this.properties.id);
// }, 1000);
},
/**
* 实时同步输入框内容
*/
noop() {
return;
},
/**
* 输入框失去焦点
*/
handleInputBlur() {
// 目前主要靠 handleBlankTap 和 handleReplySubmit 隐藏,此函数可留空
},
/**
* 提交回复
*/
handleReplySubmit() {
const { replyContent, currentReplyId } = this.data;
if (!replyContent.trim()) {
wx.showToast({ title: '回复内容不能为空', icon: 'none' });
return;
}
console.log(`[准备发送] 回复内容: "${replyContent}" | 回复目标评论ID: ${currentReplyId}`);
// --- 在此处对接您的后端API发送回复请求 ---
// wx.request({ ... });
wx.showToast({ title: '回复成功(模拟)', icon: 'success' });
this.setData({
showReplyInput: false,
currentReplyId: '',
currentReplyUsername: '',
replyContent: ''
});
},
onShareAppMessage: function (res) {
// 1. 获取文评数据,添加容错(避免数据为空导致报错)
const { cardReviewData = {} } = this.data;
@@ -104,7 +268,6 @@ Component({
getCardDetail(id: string) {
console.log("正在加载文章ID:", id);
// 1. 验证和转换 ID
const articleId = parseInt(id, 10);
if (isNaN(articleId) || articleId <= 0) {
this.setData({
@@ -114,29 +277,32 @@ Component({
return;
}
// 开始加载,重置状态
this.setData({ loading: true, error: null });
// 2. 调用获取评论的方法
this.getComments(articleId)
.then(commentsData => {
console.log("获取到commentsData:",commentsData)
// --- ✅ 核心修复逻辑在这里 ---
// 无论 commentsData 是有内容的数组还是空数组,都是成功状态
console.log("获取到 commentsData:", commentsData);
// *** 核心修改:计算总评论数 ***
const totalCount = this.calculateTotalComments(commentsData);
console.log(`文章ID ${articleId} 评论获取成功,总评论数: ${totalCount}`);
this.setData({
comments: commentsData, // 存储树形结构的数据
totalCommentsCount: totalCount, // 存储总数
comments: commentsData || [], // 确保 comments 是一个数组
totalCommentsCount: totalCount,
loading: false,
error: null // 确保成功时error 状态被清空
});
console.log(this.data.comments[0])
})
.catch(error => {
console.error(`获取评论失败: ${error.message}`);
// 只有当 getComments promise被 reject (即网络或服务器真出错了) 才会进入这里
console.error(`获取评论失败123: ${error.message}`);
this.setData({
loading: false,
// 这里只显示真实的错误信息
error: error.message || "获取评论失败,请检查网络"
});
});
@@ -151,15 +317,25 @@ Component({
articleId: articleId
},
success: (res) => {
console.log('[getComments] 收到服务器原始响应:', res.data);
const response = res.data as CommentResponse;
if (response.success && Array.isArray(response.data)) {
resolve(response.data as Comment[]);
// ✅ 核心修复 1: 同时接受 data 为数组 或 data 为 null 的情况
if (response.success && (Array.isArray(response.data) || response.data === null)) {
console.log('[getComments] 数据校验成功, resolve 数据');
// ✅ 核心修复 2: 如果 data 是 null我们将其视为空数组 [] 返回
// 这样可以保证后续的 .then 代码接收到的永远是一个数组
resolve(response.data || []);
} else {
reject(new Error(response.message || "服务器返回数据异常"));
// 只有当服务器明确告知失败, 或数据格式不对时, 才 reject
console.error('[getComments] 服务器返回数据异常, reject 错误');
reject(new Error(response.message || "服务器返回数据格式不正确"));
}
},
fail: (err) => {
console.error('[getComments] 网络请求失败, reject 错误:', err);
reject(new Error("网络请求失败,请检查连接"));
}
});

View File

@@ -1,11 +1,13 @@
<!-- 外层容器:统一页面背景 -->
<view class="page-wrapper" bindtap="handleBlankTap">
<!-- 核心内容容器:包裹投票区+评论区,消除割裂感 -->
<view class="page-wrapper" catchtap="handleBlankTap">
<scroll-view
class="content-container"
scroll-y="true"
style="height: 100vh;"
bindscroll="onPageScroll"
>
<view class="content-container">
<!-- 1. 投票区域:仅当有投票数据时显示 -->
<view class="voting-section" wx:if="{{cardReviewData}}">
<!-- 卡片头部:用户信息+投票状态 -->
<view class="card-header" bindtap="goToDetail" data-id="{{cardReviewData.article_id}}">
<view class="card-header" catchtap="goToDetail" data-id="{{cardReviewData.article_id}}">
<view class="user-info">
<image
src="https://picsum.photos/id/100/200/200"
@@ -14,21 +16,18 @@
alt="用户头像"
></image>
<view class="user-details">
<text class="username">{{cardReviewData.publisher_id}}</text>
<text class="username">{{cardReviewData.publisher_name}}</text>
<text class="user-status">发起人 ({{cardReviewData.vote_type === 'single' ? '单选' : '多选'}})</text>
</view>
</view>
<!-- 投票状态:已投票/进行中/已结束 -->
<view class="vote-status {{cardReviewData.user_has_voted ? 'status-voted' : (cardReviewData.is_ended ? 'status-ended' : 'status-active')}}">
{{cardReviewData.user_has_voted ? '已投票' : (cardReviewData.is_ended ? '已结束' : '进行中')}}
</view>
</view>
<!-- 分割线 -->
<view class="divider"></view>
<!-- 投票标题区域:含“您已投票”提示 -->
<view class="vote-title-section" bindtap="goToDetail" data-id="{{cardReviewData.article_id}}">
<view class="vote-title-section" catchtap="goToDetail" data-id="{{cardReviewData.article_id}}">
<text class="vote-title">
{{cardReviewData.article_title}}
<text class="voted-tip" wx:if="{{cardReviewData.user_has_voted}}">• 您已投票</text>
@@ -36,21 +35,18 @@
<text class="vote-desc">{{cardReviewData.article_desc || ''}}</text>
</view>
<!-- 投票选项列表 -->
<view class="options-container">
<block wx:for="{{cardReviewData.options}}" wx:for-item="option" wx:key="id">
<view
class="option-item {{(cardReviewData.user_has_voted && option.is_voted) ? 'selected voted-option' : (option.isSelected ? 'selected' : '')}} {{(cardReviewData.is_ended || cardReviewData.user_has_voted) ? 'disabled-option' : ''}}"
bindtap="{{(cardReviewData.is_ended || cardReviewData.user_has_voted) ? '' : 'selectOption'}}"
catchtap="{{(cardReviewData.is_ended || cardReviewData.user_has_voted) ? '' : 'selectOption'}}"
data-card-id="{{cardReviewData.article_id}}"
data-option-id="{{option.id}}"
>
<view class="option-info">
<!-- 选项图标:对勾/圆圈 -->
<view class="option-icon bg-gray">
<text class="iconfont">{{(cardReviewData.user_has_voted && option.is_voted) ? '✓' : (option.isSelected ? '✓' : '○')}}</text>
</view>
<!-- 选项内容 -->
<view class="option-details">
<text class="option-name">
{{option.name}}
@@ -60,18 +56,14 @@
</view>
</view>
<!-- AI标识 -->
<view class="option-action-btn">AI</view>
<!-- 投票百分比 -->
<text class="option-percent">{{option.percentage || 0}}%</text>
<!-- 进度条 -->
<view class="progress-bar">
<view class="progress-value" style="width: {{(option.percentage || 0) + '%'}}"></view>
</view>
<!-- 选中指示器 -->
<view class="vote-indicator {{(cardReviewData.user_has_voted && option.is_voted) || option.isSelected ? 'show' : ''}}">
<text class="iconfont icon-check-circle"></text>
</view>
@@ -79,20 +71,18 @@
</block>
</view>
<!-- 投票统计信息 -->
<view class="vote-stats" bindtap="goToDetail" data-id="{{cardReviewData.article_id}}">
<view class="vote-stats" catchtap="goToDetail" data-id="{{cardReviewData.article_id}}">
<text class="stats-text">已有 {{cardReviewData.total_voters || 0}} 人参与投票</text>
<text class="stats-text">
{{cardReviewData.is_ended ? '已结束' : '截止至 ' + (cardReviewData.end_time || '0天')}}
</text>
</view>
<!-- 投票按钮 -->
<view class="vote-btn-wrapper">
<button
class="vote-btn {{(cardReviewData.is_ended ? 'vote-btn-ended' : (cardReviewData.user_has_voted ? 'vote-btn-disabled' : ''))}}"
disabled="{{cardReviewData.is_ended || cardReviewData.user_has_voted}}"
bindtap="submitVote"
catchtap="submitVote"
data-card-id="{{cardReviewData.article_id}}"
>
<text class="btn-text">
@@ -102,76 +92,79 @@
<button
class="share-btn"
open-type="share"
>
<!-- 原有图标 -->
>
<t-icon
name="link-1"
class="wrapper-link-icon"
/>
</button>
</button>
</view>
</view>
<!-- 投票区状态提示:无数据/加载中/加载失败 -->
<view class="vote-status-tip" wx:if="{{!cardReviewData && !loading && !error}}">暂无投票数据</view>
<view class="vote-status-tip" wx:if="{{loading}}">投票数据加载中...</view>
<view class="vote-status-tip error" wx:if="{{error}}">加载失败:{{error}}</view>
<!-- 分割线:区分投票区和评论区 -->
<view class="section-divider"></view>
<!-- 2. 评论区域 -->
<view class="primary-comment-trigger" catchtap="handleTapPrimaryCommentInput">
<image class="avatar" src="{{userInfo.avatarUrl || 'https://picsum.photos/id/101/200/200'}}"></image>
<view class="fake-input-text">快来写下你的评论吧~</view>
</view>
<view class="comment-section">
<!-- 评论列表:有评论时显示 -->
<view wx:if="{{comments.length > 0}}">
<text class="comment-count">共 {{totalCommentsCount}} 条评论</text>
<block wx:for="{{comments}}" wx:for-item="comment" wx:key="id">
<!-- 修复错误:将模板参数改为单行,移除注释 -->
<template is="commentItem" data="{{item: comment, onCommentTap: handleCommentTap, formatter: formatter}}" />
<template is="commentItem" data="{{item: comment, formatter: formatter}}" />
</block>
</view>
<!-- 评论状态提示:无评论/加载中/加载失败 -->
<view class="comment-status" wx:else>
<view wx:if="{{!loading && !error}}">快来发布第一条评论吧!</view>
<view wx:elif="{{loading}}">评论加载中...</view>
<view wx:elif="{{error}}">加载失败: {{error}}</view>
</view>
</view>
</view>
<!-- 3. 回复输入框点击评论时弹出fixed固定在底部 -->
<view class="reply-input-container" wx:if="{{showReplyInput}}">
</scroll-view>
<view
class="reply-input-container {{showReplyInput ? 'visible' : ''}}"
style="bottom: {{keyboardHeight}}px;"
catchtap="noop"
>
<input
class="reply-input"
placeholder="请输入回复内容..."
placeholder="{{currentReplyId ? '回复 ' + (currentReplyUsername || '评论') : '善语结善缘,恶言伤人心'}}"
value="{{replyContent}}"
focus="{{showReplyInput}}"
confirm-type="send"
bindconfirm="handleReplySubmit"
bindinput="handleReplyInput"
bindblur="handleInputBlur"
bindkeyboardheightchange="onKeyboardHeightChange"
/>
<button
class="reply-submit-btn"
bindtap="handleReplySubmit"
disabled="{{!replyContent.trim()}}"
class="reply-submit-btn {{isSubmitDisabled ? 'disabled' : ''}}"
catchtap="handleReplySubmit"
>
发送
</button>
</view>
</view>
</view>
<!-- WXS工具时间格式化评论时间显示用 -->
<wxs src="./utils.wxs" module="formatter" />
<!-- 评论项模板:主评论+子评论通用 -->
<template name="commentItem">
<view
class="comment-item {{item.parent_id ? 'reply-level' : 'top-level'}}"
bindtap="{{onCommentTap}}"
data-comment-id="{{item.id}}"
data-comment-type="{{item.parent_id ? 'sub' : 'main'}}"
catchtap="handleCommentTap"
data-item="{{item}}"
>
<!-- 评论头部:用户头像+用户名 -->
<view class="comment-header">
<image
class="avatar"
@@ -184,19 +177,16 @@
</view>
</view>
<!-- 评论内容 -->
<view class="comment-content">
<text>{{item.content}}</text>
</view>
<!-- 评论底部:时间+点赞+点踩 -->
<view class="comment-footer">
<view class="footer-left">
{{formatter.formatIsoDateTime(item.created_at)}} <!-- 格式化评论时间 -->
{{formatter.formatIsoDateTime(item.created_at)}}
</view>
<view class="footer-right">
<!-- 点赞按钮用Vant Icon -->
<view class="like-icon-wrap" bindtap="handleLike" data-id="{{item.id}}">
<view class="like-icon-wrap" catchtap="handleLike" data-id="{{item.id}}">
<van-icon
class="like-icon {{item.is_liked ? 'liked' : ''}}"
name="{{item.is_liked ? 'good-job' : 'good-job-o'}}"
@@ -204,8 +194,7 @@
/>
<text class="likes-count">{{item.likes_count || 0}}</text>
</view>
<!-- 点踩按钮:自定义图片 -->
<view class="dislike-icon-wrap" bindtap="handleDislike" data-id="{{item.id}}">
<view class="dislike-icon-wrap" catchtap="handleDislike" data-id="{{item.id}}">
<image
class="dislike-icon {{item.is_disliked ? 'disliked' : ''}}"
src="/images/dislike.png"
@@ -217,14 +206,12 @@
</view>
</view>
<!-- 子评论容器:有子评论时显示 -->
<view class="replies-container" wx:if="{{item.replies && item.replies.length > 0}}">
<block wx:for="{{item.replies}}" wx:for-item="reply" wx:key="id">
<view
class="comment-item reply-level sub-comment"
bindtap="{{onCommentTap}}"
data-comment-id="{{reply.id}}"
data-comment-type="sub"
catchtap="handleCommentTap"
data-item="{{reply}}"
>
<view class="comment-header">
<image
@@ -247,7 +234,7 @@
{{formatter.formatIsoDateTime(reply.created_at)}}
</view>
<view class="footer-right">
<view class="like-icon-wrap" bindtap="handleLike" data-id="{{reply.id}}">
<view class="like-icon-wrap" catchtap="handleLike" data-id="{{reply.id}}">
<van-icon
class="like-icon {{reply.is_liked ? 'liked' : ''}}"
name="{{reply.is_liked ? 'good-job' : 'good-job-o'}}"
@@ -255,7 +242,7 @@
/>
<text class="likes-count">{{reply.likes_count || 0}}</text>
</view>
<view class="dislike-icon-wrap" bindtap="handleDislike" data-id="{{reply.id}}">
<view class="dislike-icon-wrap" catchtap="handleDislike" data-id="{{reply.id}}">
<image
class="dislike-icon {{reply.is_disliked ? 'disliked' : ''}}"
src="/images/dislike.png"
@@ -270,7 +257,6 @@
</block>
</view>
<!-- 评论分割线 -->
<view class="comment-divider"></view>
</view>
</template>

View File

@@ -563,52 +563,108 @@
font-size: 22rpx;
}
/* 新增:回复输入框容器(固定在底部,全屏宽度) */
.reply-input-container {
.reply-input-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 80rpx;
background-color: #fff;
border-top: 1rpx solid #f5f5f5;
background-color: #FFFFFF;
border-top: 1rpx solid #EFEFEF;
display: flex;
align-items: center;
padding: 0 20rpx;
padding: 16rpx 24rpx;
/* 关键:为全面屏手机底部安全区域留出空间 */
padding-bottom: calc(16rpx + constant(safe-area-inset-bottom));
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
z-index: 999; /* 提高层级,确保在所有内容上方 */
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05); /* 增加阴影,突出“弹出”感 */
z-index: 999;
/* 初始状态在屏幕下方 */
transform: translateY(100%);
/* 柔和的阴影 */
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.04);
/* 动画效果持续0.3秒的缓入缓出动画 */
transition: transform 0.3s ease-in-out;
}
/* 当容器有 'visible' 类时,滑入屏幕内 */
.reply-input-container.visible {
transform: translateY(0);
}
/* 输入框样式 */
.reply-input {
flex: 1; /* 占满剩余宽度 */
height: 56rpx;
background-color: #f5f5f5;
border-radius: 28rpx; /* 圆角,更美观 */
padding: 0 20rpx;
flex: 1;
height: 72rpx; /* 增加高度,更大气 */
background-color: #f4f5f7;
border-radius: 36rpx;
padding: 0 30rpx;
box-sizing: border-box;
font-size: 24rpx;
font-size: 28rpx;
color: #333;
}
/* 输入框 placeholder 样式 */
.reply-input::placeholder {
color: #BDBDBD;
}
/* 提交按钮样式 */
.reply-submit-btn {
width: 120rpx;
height: 56rpx;
line-height: 56rpx;
background-color: #4F46E5;
height: 72rpx;
line-height: 72rpx;
background: linear-gradient(90deg, #5A67D8, #4C51BF); /* 使用渐变色 */
color: #fff;
border-radius: 28rpx;
border-radius: 36rpx;
margin-left: 16rpx;
font-size: 24rpx;
padding: 0; /* 清除默认内边距 */
font-size: 28rpx;
padding: 0;
border: none; /* 移除 button 默认边框 */
/* 动画效果:为背景色、透明度和缩放添加过渡 */
transition: background-color 0.3s, opacity 0.3s, transform 0.2s;
}
/* 提交按钮禁用样式(可选:内容为空时禁用) */
.reply-submit-btn:disabled {
background-color: #cccccc;
color: #888;
/* 移除按钮点击时的默认覆盖样式 */
.reply-submit-btn::after {
display: none;
}
/* 按钮的禁用样式 */
.reply-submit-btn.disabled {
background: #E0E0E0; /* 更柔和的禁用灰色 */
color: #AFAFAF;
/* 禁用时缩小一点点,提供视觉反馈 */
transform: scale(0.95);
opacity: 0.8;
}
.primary-comment-trigger {
display: flex;
align-items: center;
margin-top: 20px;
padding: 10px 0;
border-top: 1px solid #f0f0f0;
}
.primary-comment-trigger .avatar {
width: 32px;
height: 32px;
border-radius: 50%;
margin-right: 12px;
}
.primary-comment-trigger .fake-input-text {
flex: 1;
background-color: #f7f7f7;
height: 36px;
line-height: 36px;
border-radius: 18px;
padding: 0 15px;
color: #aaa;
font-size: 14px;
}
/* 原有样式不变... */

View File

@@ -24,7 +24,7 @@
<view class="user-info">
<image src="https://picsum.photos/id/100/200/200" mode="widthFix" class="avatar" alt="用户头像"></image>
<view class="user-details">
<text class="username">{{card.publisher_id}}</text>
<text class="username">{{card.publisher_name}}</text>
<text class="user-status">({{card.vote_type === 'single' ? '单选' : '多选'}})</text>
</view>
</view>