detail.vue 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518
  1. <template>
  2. <view class="detail-page">
  3. <view class="status-bar-placeholder" :style="{height: statusBarHeight + 'px', backgroundColor: 'transparent'}"></view>
  4. <!-- 自定义导航栏 -->
  5. <view class="custom-navbar">
  6. <view class="navbar-background">
  7. <view class="feather feather-small">🪶</view>
  8. </view>
  9. <view class="navbar-left" @click="goBack">
  10. <text class="back-icon">←</text>
  11. </view>
  12. <view class="navbar-title">
  13. <text class="main-title">💕 爱的故事</text>
  14. <text class="sub-title">两只羽毛的浪漫时光</text>
  15. </view>
  16. </view>
  17. <!-- 内容区域 -->
  18. <scroll-view
  19. class="content-scroll"
  20. scroll-y
  21. :scroll-into-view="scrollIntoView"
  22. scroll-with-animation
  23. >
  24. <!-- 动态内容 -->
  25. <view v-if="dynamic" class="dynamic-detail">
  26. <!-- 用户信息 -->
  27. <view class="user-section">
  28. <image
  29. :src="getAvatar(dynamic)"
  30. class="avatar"
  31. mode="aspectFill"
  32. ></image>
  33. <view class="user-info">
  34. <text class="nickname">{{ getNickname(dynamic) }}</text>
  35. <text class="time">{{ formatTime(dynamic.createdAt) }}</text>
  36. </view>
  37. <view class="navbar-right" @click="showMoreMenu">
  38. <text class="more-icon">⋯</text>
  39. </view>
  40. </view>
  41. <!-- 动态内容 -->
  42. <view class="content-section">
  43. <text class="content-text">{{ dynamic.content }}</text>
  44. </view>
  45. <!-- 媒体内容 -->
  46. <view v-if="dynamic.mediaUrls && dynamic.mediaUrls.length > 0" class="media-section">
  47. <view class="image-grid">
  48. <image
  49. v-for="(url, index) in dynamic.mediaUrls"
  50. :key="index"
  51. :src="url"
  52. class="media-image"
  53. mode="aspectFill"
  54. @click="previewImage(dynamic.mediaUrls, index)"
  55. ></image>
  56. </view>
  57. </view>
  58. <!-- 互动数据 -->
  59. <view class="stats-section">
  60. <text class="stat-item">{{ dynamic.likeCount || 0 }} 点赞</text>
  61. <text class="stat-item">{{ dynamic.commentCount || 0 }} 评论</text>
  62. <text class="stat-item">{{ dynamic.favoriteCount || 0 }} 收藏</text>
  63. <text class="stat-item">{{ dynamic.viewCount || 0 }} 浏览</text>
  64. </view>
  65. </view>
  66. <!-- 评论区域 -->
  67. <view id="comment-section" class="comment-section">
  68. <view class="section-title">
  69. <text class="title-text">评论区</text>
  70. <text class="title-count">{{ getCommentCount() }}</text>
  71. </view>
  72. <!-- 评论列表 -->
  73. <view v-if="commentList.length > 0" class="comment-list">
  74. <view v-for="comment in commentList"
  75. :key="comment.commentId"
  76. class="comment-item"
  77. >
  78. <image
  79. :src="getCommentAvatar(comment)"
  80. class="comment-avatar"
  81. ></image>
  82. <view class="comment-content">
  83. <text class="comment-nickname">{{ getCommentNickname(comment) }}</text>
  84. <text class="comment-text">{{ comment.content }}</text>
  85. <!-- 评论图片 -->
  86. <view v-if="getCommentImages(comment).length > 0" class="comment-images">
  87. <image
  88. v-for="(imgUrl, imgIndex) in getCommentImages(comment)"
  89. :key="imgIndex"
  90. :src="imgUrl"
  91. class="comment-image"
  92. mode="aspectFill"
  93. @click="previewCommentImage(getCommentImages(comment), imgIndex)"
  94. />
  95. </view>
  96. <!-- 子回复(一级展示) -->
  97. <view class="reply-list" v-if="comment.replies && comment.replies.length > 0">
  98. <view class="reply-item" v-for="reply in comment.replies" :key="'r-'+reply.commentId">
  99. <image :src="getCommentAvatar(reply)" class="reply-avatar" />
  100. <view class="reply-info">
  101. <text class="reply-nickname">{{ getCommentNickname(reply) }}:</text>
  102. <text class="reply-text">{{ reply.content }}</text>
  103. <text class="reply-time">{{ formatTime(reply.createdAt) }}</text>
  104. </view>
  105. </view>
  106. </view>
  107. <view class="comment-footer">
  108. <text class="comment-time">{{ formatTime(comment.createdAt) }}</text>
  109. <view class="comment-actions">
  110. <text class="action" @click="toggleCommentLike(comment)">{{ (comment.isLiked ? '❤️' : '🤍') + ' ' + (comment.likeCount || 0) }}</text>
  111. <text class="action" @click="openReply(comment)">回复</text>
  112. </view>
  113. </view>
  114. </view>
  115. </view>
  116. </view>
  117. <!-- 暂无评论 -->
  118. <view v-else class="empty-comment">
  119. <view class="feather-decoration">
  120. <text class="feather-small">🪶</text>
  121. <text class="feather-small">🪶</text>
  122. </view>
  123. <text class="empty-icon">💕</text>
  124. <text class="empty-text">还没有人留下爱的足迹</text>
  125. <text class="empty-hint">成为第一个点赞这份美好的人吧~</text>
  126. </view>
  127. </view>
  128. </scroll-view>
  129. <!-- 底部操作栏 -->
  130. <view class="bottom-bar">
  131. <view class="input-area" @click="showCommentInput">
  132. <text class="love-prefix">💕</text>
  133. <text class="input-placeholder">留下你的爱意表达...</text>
  134. </view>
  135. <view class="action-buttons">
  136. <view class="action-btn" @click="handleLike">
  137. <text :class="['action-icon', dynamic && dynamic.isLiked ? 'active' : '']">
  138. {{ dynamic && dynamic.isLiked ? '❤️' : '🤍' }}
  139. </text>
  140. <text class="action-label">{{ (dynamic && dynamic.likeCount) ? dynamic.likeCount : 0 }}</text>
  141. </view>
  142. <view class="action-btn" @click="handleFavorite">
  143. <text :class="['action-icon', dynamic && dynamic.isFavorited ? 'active' : '']">
  144. {{ dynamic && dynamic.isFavorited ? '⭐' : '☆' }}
  145. </text>
  146. <text class="action-label">{{ (dynamic && dynamic.favoriteCount) ? dynamic.favoriteCount : 0 }}</text>
  147. </view>
  148. </view>
  149. </view>
  150. <!-- 更多菜单弹窗 -->
  151. <view v-if="showMenu" class="menu-popup">
  152. <view class="menu-mask" @click="hideMoreMenu"></view>
  153. <view class="menu-content">
  154. <!-- 如果是自己的动态,显示编辑和删除 -->
  155. <template v-if="isMyDynamic">
  156. <view class="menu-item" @click="handleEdit">
  157. <text class="menu-icon">✏️</text>
  158. <text class="menu-text">编辑动态</text>
  159. </view>
  160. <view class="menu-item" @click="handleDelete">
  161. <text class="menu-icon">🗑️</text>
  162. <text class="menu-text">删除动态</text>
  163. </view>
  164. </template>
  165. <!-- 如果不是自己的动态,显示举报 -->
  166. <template v-else>
  167. <view class="menu-item" @click="handleReport">
  168. <text class="menu-icon">🚩</text>
  169. <text class="menu-text">举报</text>
  170. </view>
  171. </template>
  172. <view class="menu-item cancel" @click="hideMoreMenu">
  173. <text class="menu-text">取消</text>
  174. </view>
  175. </view>
  176. </view>
  177. <!-- 评论输入弹窗 -->
  178. <view v-if="showInput" class="comment-popup">
  179. <view class="popup-mask" @click="hideCommentInput"></view>
  180. <view class="popup-content">
  181. <textarea
  182. v-model="commentText"
  183. placeholder="输入评论内容..."
  184. class="comment-textarea"
  185. :focus="showInput"
  186. maxlength="500"
  187. />
  188. <!-- 图片选择区域 -->
  189. <view class="image-section">
  190. <view class="image-list">
  191. <view v-for="(img, index) in commentImages" :key="index" class="image-item">
  192. <image :src="img" mode="aspectFill" class="preview-image"/>
  193. <text class="remove-image" @click="removeCommentImage(index)">×</text>
  194. </view>
  195. <view v-if="commentImages.length < 3" class="add-image" @click="chooseCommentImage">
  196. <text class="add-icon">📷</text>
  197. <text class="add-text">添加图片</text>
  198. </view>
  199. </view>
  200. </view>
  201. <view class="popup-footer">
  202. <text class="char-count">{{ commentText.length }}/500</text>
  203. <button class="submit-btn" @click="submitComment">发送</button>
  204. </view>
  205. </view>
  206. </view>
  207. </view>
  208. </template>
  209. <script>
  210. import api from '@/utils/api.js'
  211. export default {
  212. data() {
  213. return {
  214. statusBarHeight: 0,
  215. dynamicId: null,
  216. dynamic: null,
  217. commentList: [],
  218. commentTotal: 0,
  219. showInput: false,
  220. showMenu: false,
  221. commentText: '',
  222. commentImages: [],
  223. scrollIntoView: '',
  224. currentUserId: 1,
  225. defaultAvatar: 'http://115.190.125.125:9001/static-images/default-avatar.svg',
  226. isLiking: false,
  227. isFavoriting: false,
  228. replyingToComment: null,
  229. }
  230. },
  231. computed: {
  232. isMyDynamic() {
  233. if (!this.dynamic || !this.currentUserId) return false
  234. return this.dynamic.userId === this.currentUserId ||
  235. (this.dynamic.user && this.dynamic.user.userId === this.currentUserId)
  236. }
  237. },
  238. onLoad(options) {
  239. // 统一处理动态ID
  240. this.dynamicId = options.id || options.dynamicId
  241. if (this.dynamicId) {
  242. // 获取当前用户ID
  243. this.getCurrentUserId()
  244. // 加载数据
  245. this.loadDynamicDetail()
  246. this.loadComments()
  247. // 滚动到评论区
  248. if (options.scrollToComment === 'true') {
  249. this.$nextTick(() => {
  250. setTimeout(() => {
  251. this.scrollIntoView = 'comment-section'
  252. }, 500)
  253. })
  254. }
  255. }
  256. //获取状态栏高度
  257. const systemInfo = uni.getSystemInfoSync()
  258. this.statusBarHeight = systemInfo.statusBarHeight
  259. },
  260. methods: {
  261. // 获取当前用户ID
  262. getCurrentUserId() {
  263. const storedUserId = uni.getStorageSync('userId')
  264. if (storedUserId !== null && storedUserId !== undefined && storedUserId !== '') {
  265. this.currentUserId = parseInt(storedUserId)
  266. } else {
  267. const userInfo = uni.getStorageSync('userInfo')
  268. if (userInfo && (userInfo.userId || userInfo.id || userInfo.user_id)) {
  269. this.currentUserId = parseInt(userInfo.userId || userInfo.id || userInfo.user_id)
  270. } else {
  271. this.currentUserId = null
  272. }
  273. }
  274. },
  275. // 获取用户头像
  276. getAvatar(item) {
  277. if (item && item.user && item.user.avatarUrl) {
  278. return item.user.avatarUrl
  279. }
  280. return this.defaultAvatar
  281. },
  282. // 获取用户昵称
  283. getNickname(item) {
  284. if (item && item.user && item.user.nickname) {
  285. return item.user.nickname
  286. }
  287. return '匿名用户'
  288. },
  289. // 获取评论头像
  290. getCommentAvatar(comment) {
  291. if (comment && comment.user && comment.user.avatarUrl) {
  292. return comment.user.avatarUrl
  293. }
  294. if (comment && comment.$orig && comment.$orig.user && comment.$orig.user.avatarUrl) {
  295. return comment.$orig.user.avatarUrl
  296. }
  297. return this.defaultAvatar
  298. },
  299. // 获取评论昵称
  300. getCommentNickname(comment) {
  301. if (comment && comment.user && comment.user.nickname) {
  302. return comment.user.nickname
  303. }
  304. if (comment && comment.$orig && comment.$orig.user && comment.$orig.user.nickname) {
  305. return comment.$orig.user.nickname
  306. }
  307. return '匿名用户'
  308. },
  309. // 获取评论图片列表
  310. getCommentImages(comment) {
  311. if (!comment) return []
  312. const pickFirstAvailable = (obj) => {
  313. if (!obj) return null
  314. return obj.imageUrls || obj.images || obj.image_list || obj.imageList || null
  315. }
  316. const raw = pickFirstAvailable(comment)
  317. if (!raw) return []
  318. const isLikelyImage = (u) => {
  319. if (typeof u !== 'string' || !u.trim()) return false
  320. const url = u.trim()
  321. const hasExt = /(\.png|\.jpg|\.jpeg|\.gif|\.webp|\.bmp)(\?|$)/i.test(url)
  322. const isHttp = /^https?:\/\//i.test(url)
  323. const isMinio = url.includes('dynamic-comments/')
  324. return hasExt || (isHttp && isMinio)
  325. }
  326. try {
  327. if (Array.isArray(raw)) {
  328. return raw.filter(isLikelyImage)
  329. }
  330. if (typeof raw === 'string') {
  331. const s = raw.trim()
  332. if (s.startsWith('[')) {
  333. const arr = JSON.parse(s)
  334. return Array.isArray(arr) ? arr.filter(isLikelyImage) : []
  335. }
  336. return s.split(',')
  337. .map(x => x.trim().replace(/^\[|\]$/g, '').replace(/^['\"]|['\"]$/g, ''))
  338. .filter(isLikelyImage)
  339. }
  340. } catch (e) {
  341. console.error('解析评论图片失败:', e)
  342. }
  343. return []
  344. },
  345. // 预览评论图片
  346. previewCommentImage(urls, current) {
  347. uni.previewImage({
  348. urls: urls,
  349. current: current
  350. })
  351. },
  352. // 加载动态详情
  353. async loadDynamicDetail() {
  354. uni.showLoading({ title: '加载中...' })
  355. try {
  356. const res = await api.dynamic.getDetail(this.dynamicId, this.currentUserId)
  357. if (res) {
  358. this.dynamic = {
  359. ...res,
  360. isLiked: Boolean(res.isLiked === true || res.isLiked === 1 || res.isLiked === 'true' || res.isLiked === '1'),
  361. isFavorited: Boolean(res.isFavorited === true || res.isFavorited === 1 || res.isFavorited === 'true' || res.isFavorited === '1')
  362. }
  363. }
  364. } catch (error) {
  365. console.error('加载详情失败:', error)
  366. uni.showToast({
  367. title: '加载失败',
  368. icon: 'none'
  369. })
  370. } finally {
  371. uni.hideLoading()
  372. }
  373. },
  374. // 处理点赞
  375. async handleLike() {
  376. if (!this.dynamic || this.isLiking) return
  377. try {
  378. this.isLiking = true
  379. const isLiked = this.dynamic.isLiked
  380. const originalCount = this.dynamic.likeCount || 0
  381. // 乐观更新UI
  382. this.dynamic.isLiked = !isLiked
  383. this.dynamic.likeCount = isLiked ? Math.max(0, originalCount - 1) : originalCount + 1
  384. // 发送请求
  385. if (isLiked) {
  386. await api.dynamic.unlike(this.dynamic.dynamicId)
  387. } else {
  388. await api.dynamic.like(this.dynamic.dynamicId)
  389. }
  390. // 通知列表页同步状态
  391. uni.$emit('dynamic-updated', {
  392. dynamicId: this.dynamic.dynamicId,
  393. isLiked: this.dynamic.isLiked,
  394. likeCount: this.dynamic.likeCount,
  395. isFavorited: this.dynamic.isFavorited,
  396. favoriteCount: this.dynamic.favoriteCount
  397. })
  398. } catch (error) {
  399. console.error('点赞失败:', error)
  400. // 恢复原状态
  401. const currentIsLiked = this.dynamic.isLiked
  402. this.dynamic.isLiked = !currentIsLiked
  403. this.dynamic.likeCount = !currentIsLiked ? (this.dynamic.likeCount || 0) + 1 : Math.max(0, (this.dynamic.likeCount || 0) - 1)
  404. uni.showToast({
  405. title: '操作失败,请重试',
  406. icon: 'none'
  407. })
  408. } finally {
  409. setTimeout(() => {
  410. this.isLiking = false
  411. }, 300)
  412. }
  413. },
  414. // 处理收藏
  415. async handleFavorite() {
  416. if (!this.dynamic || this.isFavoriting) return
  417. try {
  418. this.isFavoriting = true
  419. const isFavorited = this.dynamic.isFavorited
  420. const originalCount = this.dynamic.favoriteCount || 0
  421. // 乐观更新UI
  422. this.dynamic.isFavorited = !isFavorited
  423. this.dynamic.favoriteCount = isFavorited ? Math.max(0, originalCount - 1) : originalCount + 1
  424. if (isFavorited) {
  425. await api.dynamic.unfavorite(this.dynamic.dynamicId)
  426. } else {
  427. await api.dynamic.favorite(this.dynamic.dynamicId)
  428. uni.showToast({
  429. title: '收藏成功',
  430. icon: 'success'
  431. })
  432. }
  433. // 通知列表页同步状态
  434. uni.$emit('dynamic-updated', {
  435. dynamicId: this.dynamic.dynamicId,
  436. isLiked: this.dynamic.isLiked,
  437. likeCount: this.dynamic.likeCount,
  438. isFavorited: this.dynamic.isFavorited,
  439. favoriteCount: this.dynamic.favoriteCount
  440. })
  441. } catch (error) {
  442. console.error('收藏失败:', error)
  443. // 恢复原状态
  444. const currentIsFavorited = this.dynamic.isFavorited
  445. this.dynamic.isFavorited = !currentIsFavorited
  446. this.dynamic.favoriteCount = !currentIsFavorited ? (this.dynamic.favoriteCount || 0) + 1 : Math.max(0, (this.dynamic.favoriteCount || 0) - 1)
  447. uni.showToast({
  448. title: '操作失败,请重试',
  449. icon: 'none'
  450. })
  451. } finally {
  452. setTimeout(() => {
  453. this.isFavoriting = false
  454. }, 300)
  455. }
  456. },
  457. // 显示评论输入
  458. showCommentInput() {
  459. this.showInput = true
  460. },
  461. // 隐藏评论输入
  462. hideCommentInput() {
  463. this.showInput = false
  464. this.commentText = ''
  465. this.commentImages = []
  466. this.replyingToComment = null
  467. },
  468. // 选择评论图片
  469. async chooseCommentImage() {
  470. uni.chooseImage({
  471. count: 3 - this.commentImages.length,
  472. sizeType: ['compressed'],
  473. success: async (res) => {
  474. const filePaths = res.tempFilePaths || []
  475. if (filePaths.length === 0) return
  476. this.commentImages.push(...filePaths)
  477. uni.showLoading({ title: `正在上传图片...` })
  478. try {
  479. for (let i = 0; i < filePaths.length; i++) {
  480. const filePath = filePaths[i]
  481. try {
  482. const url = await api.dynamic.uploadSingle(filePath)
  483. const localIndex = this.commentImages.indexOf(filePath)
  484. if (localIndex !== -1) {
  485. this.commentImages.splice(localIndex, 1, url)
  486. }
  487. } catch(error) {
  488. const localIndex = this.commentImages.indexOf(filePath)
  489. if (localIndex !== -1) {
  490. this.commentImages.splice(localIndex, 1)
  491. }
  492. }
  493. }
  494. uni.hideLoading()
  495. } catch(e) {
  496. uni.hideLoading()
  497. uni.showToast({ title: '图片上传失败', icon: 'none' })
  498. }
  499. },
  500. fail: () => {
  501. uni.showToast({ title: '选择图片失败', icon: 'none' })
  502. }
  503. })
  504. },
  505. // 移除评论图片
  506. removeCommentImage(index) {
  507. this.commentImages.splice(index, 1)
  508. },
  509. // 加载评论(核心修复:子评论嵌套)
  510. async loadComments() {
  511. try {
  512. uni.showLoading({ title: '加载评论...', mask: false })
  513. const res = await api.dynamic.getComments(this.dynamicId, 1, 20)
  514. const allComments = Array.isArray(res?.records) ? res.records : []
  515. // 1. 分离父评论和子评论
  516. const parentComments = allComments.filter(c => {
  517. const parentId = c.parentCommentId || 0
  518. return parentId === 0 || parentId === null || parentId === undefined
  519. })
  520. const childComments = allComments.filter(c => {
  521. const parentId = c.parentCommentId || 0
  522. return parentId !== 0 && parentId !== null && parentId !== undefined
  523. })
  524. // 2. 将子评论嵌套到对应的父评论中
  525. parentComments.forEach(parent => {
  526. // 初始化replies数组
  527. parent.replies = parent.replies || []
  528. // 匹配子评论
  529. const matchedReplies = childComments.filter(child => {
  530. return child.parentCommentId === parent.commentId
  531. })
  532. // 赋值子评论
  533. if (matchedReplies.length > 0) {
  534. parent.replies = matchedReplies
  535. }
  536. })
  537. // 3. 更新数据
  538. this.commentList = parentComments
  539. this.commentTotal = res.total !== undefined ? res.total : parentComments.length
  540. // 同步更新动态的评论数
  541. if (this.dynamic) {
  542. this.dynamic.commentCount = this.commentTotal
  543. }
  544. console.log('评论数据加载完成:', {
  545. total: this.commentTotal,
  546. parentCount: parentComments.length,
  547. childCount: childComments.length
  548. })
  549. } catch (e) {
  550. console.error('加载评论失败:', e)
  551. this.commentList = []
  552. this.commentTotal = 0
  553. uni.showToast({
  554. title: '评论加载失败',
  555. icon: 'none'
  556. })
  557. } finally {
  558. uni.hideLoading()
  559. }
  560. },
  561. // 获取评论数
  562. getCommentCount() {
  563. if (this.commentTotal > 0) {
  564. return this.commentTotal
  565. }
  566. if (this.commentList && this.commentList.length > 0) {
  567. return this.commentList.length
  568. }
  569. return this.dynamic ? (this.dynamic.commentCount || 0) : 0
  570. },
  571. // 提交评论
  572. async submitComment() {
  573. if (!this.commentText.trim()) {
  574. uni.showToast({
  575. title: '请输入评论内容',
  576. icon: 'none'
  577. })
  578. return
  579. }
  580. try {
  581. const imageUrls = this.commentImages.length > 0 ? this.commentImages.join(',') : ''
  582. const parentCommentId = this.replyingToComment ? this.replyingToComment.commentId : 0
  583. await api.dynamic.addComment(this.dynamicId, this.commentText, imageUrls, parentCommentId)
  584. // 重置状态
  585. this.commentText = ''
  586. this.commentImages = []
  587. this.hideCommentInput()
  588. // 乐观更新评论数
  589. this.commentTotal = (this.commentTotal || 0) + 1
  590. if (this.dynamic) {
  591. this.dynamic.commentCount = (this.dynamic.commentCount || 0) + 1
  592. }
  593. // 重新加载评论
  594. this.loadComments()
  595. uni.showToast({ title: '评论发布成功', icon: 'success' })
  596. } catch (e) {
  597. console.error('提交评论失败:', e)
  598. uni.showToast({ title: '发布失败,请重试', icon: 'none' })
  599. }
  600. },
  601. // 评论点赞/取消
  602. async toggleCommentLike(comment) {
  603. try {
  604. if (comment.isLiked) {
  605. await api.dynamic.unlikeComment(comment.commentId)
  606. comment.isLiked = false
  607. comment.likeCount = Math.max(0, (comment.likeCount || 0) - 1)
  608. } else {
  609. await api.dynamic.likeComment(comment.commentId)
  610. comment.isLiked = true
  611. comment.likeCount = (comment.likeCount || 0) + 1
  612. }
  613. } catch (e) {
  614. console.error('评论点赞失败:', e)
  615. }
  616. },
  617. // 打开回复框
  618. openReply(comment) {
  619. this.replyingToComment = comment
  620. this.showInput = true
  621. this.scrollIntoView = ''
  622. // 滚动到评论区(可选)
  623. this.$nextTick(() => {
  624. this.scrollIntoView = 'comment-section'
  625. })
  626. },
  627. // 显示更多菜单
  628. showMoreMenu() {
  629. this.showMenu = true
  630. },
  631. // 隐藏更多菜单
  632. hideMoreMenu() {
  633. this.showMenu = false
  634. },
  635. // 处理编辑
  636. handleEdit() {
  637. this.hideMoreMenu()
  638. if (!this.dynamic || !this.dynamicId) {
  639. uni.showToast({
  640. title: '动态信息不存在',
  641. icon: 'none'
  642. })
  643. return
  644. }
  645. const mediaUrls = Array.isArray(this.dynamic.mediaUrls)
  646. ? this.dynamic.mediaUrls
  647. : (this.dynamic.mediaUrls ? JSON.parse(this.dynamic.mediaUrls) : [])
  648. uni.navigateTo({
  649. url: `/pages/plaza/publish?edit=true&dynamicId=${this.dynamicId}&content=${encodeURIComponent(this.dynamic.content || '')}&mediaUrls=${encodeURIComponent(JSON.stringify(mediaUrls))}&mediaType=${this.dynamic.mediaType || 0}&visibility=${this.dynamic.visibility || 1}`
  650. })
  651. },
  652. // 处理删除
  653. handleDelete() {
  654. this.hideMoreMenu()
  655. if (!this.dynamicId) {
  656. uni.showToast({
  657. title: '动态ID不存在',
  658. icon: 'none'
  659. })
  660. return
  661. }
  662. uni.showModal({
  663. title: '确认删除',
  664. content: '确定要删除这条动态吗?删除后无法恢复。',
  665. confirmText: '删除',
  666. confirmColor: '#E91E63',
  667. success: async (res) => {
  668. if (res.confirm) {
  669. try {
  670. if (!this.currentUserId) {
  671. uni.showToast({
  672. title: '请先登录',
  673. icon: 'none'
  674. })
  675. return
  676. }
  677. uni.showLoading({
  678. title: '删除中...'
  679. })
  680. await api.dynamic.deleteUserDynamic(this.dynamicId, this.currentUserId)
  681. uni.hideLoading()
  682. uni.showToast({
  683. title: '删除成功',
  684. icon: 'success'
  685. })
  686. setTimeout(() => {
  687. uni.navigateBack()
  688. }, 1500)
  689. } catch (error) {
  690. uni.hideLoading()
  691. console.error('删除动态失败:', error)
  692. uni.showToast({
  693. title: error.message || '删除失败',
  694. icon: 'none'
  695. })
  696. }
  697. }
  698. }
  699. })
  700. },
  701. // 处理举报
  702. handleReport() {
  703. this.hideMoreMenu()
  704. if (!this.dynamicId) {
  705. uni.showToast({
  706. title: '动态ID不存在',
  707. icon: 'none'
  708. })
  709. return
  710. }
  711. uni.navigateTo({
  712. url: `/pages/plaza/report?id=${this.dynamicId}`
  713. })
  714. },
  715. // 预览图片
  716. previewImage(urls, current) {
  717. uni.previewImage({
  718. urls: urls,
  719. current: current
  720. })
  721. },
  722. // 格式化时间
  723. formatTime(timeStr) {
  724. if (!timeStr) return ''
  725. const time = new Date(timeStr)
  726. if (isNaN(time.getTime())) return ''
  727. const now = new Date()
  728. const diff = now - time
  729. if (diff < 60000) return '刚刚'
  730. if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'
  731. if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'
  732. if (diff < 604800000) return Math.floor(diff / 86400000) + '天前'
  733. const year = time.getFullYear()
  734. const month = String(time.getMonth() + 1).padStart(2, '0')
  735. const day = String(time.getDate()).padStart(2, '0')
  736. const hour = String(time.getHours()).padStart(2, '0')
  737. const minute = String(time.getMinutes()).padStart(2, '0')
  738. if (year === now.getFullYear()) {
  739. return `${month}-${day} ${hour}:${minute}`
  740. }
  741. return `${year}-${month}-${day}`
  742. },
  743. // 返回
  744. goBack() {
  745. uni.navigateBack()
  746. }
  747. }
  748. }
  749. </script>
  750. <style lang="scss" scoped>
  751. .detail-page {
  752. min-height: 100vh;
  753. background: #FFF0F5;
  754. // 自定义导航栏
  755. .custom-navbar {
  756. position: fixed;
  757. top: 0;
  758. left: 0;
  759. right: 0;
  760. height: 88rpx;
  761. background: transparent;
  762. display: flex;
  763. align-items: center;
  764. padding: 0 30rpx;
  765. padding-top: 0;
  766. z-index: 1000;
  767. box-shadow: none;
  768. overflow: visible;
  769. position: relative;
  770. .navbar-background {
  771. position: absolute;
  772. top: 0;
  773. left: 0;
  774. right: 0;
  775. bottom: 0;
  776. .feather-small {
  777. position: absolute;
  778. font-size: 40rpx;
  779. opacity: 0.15;
  780. top: 50%;
  781. left: 50%;
  782. transform: translate(-50%, -50%) rotate(25deg);
  783. animation: float 4s ease-in-out infinite;
  784. }
  785. }
  786. .navbar-left,
  787. .navbar-right {
  788. width: 100rpx;
  789. z-index: 1;
  790. }
  791. .navbar-left {
  792. .back-icon {
  793. font-size: 40rpx;
  794. color: #E91E63;
  795. text-shadow: none;
  796. }
  797. }
  798. .navbar-title {
  799. flex: 1;
  800. text-align: center;
  801. z-index: 1;
  802. .main-title {
  803. display: block;
  804. font-size: 30rpx;
  805. font-weight: 700;
  806. color: #D81B60;
  807. text-shadow: none;
  808. margin-bottom: 2rpx;
  809. }
  810. .sub-title {
  811. display: block;
  812. font-size: 20rpx;
  813. color: #AD1457;
  814. letter-spacing: 1rpx;
  815. }
  816. }
  817. .navbar-right {
  818. display: flex;
  819. justify-content: flex-end;
  820. align-items: center;
  821. .more-icon {
  822. font-size: 48rpx;
  823. color: #E91E63;
  824. font-weight: bold;
  825. line-height: 1;
  826. transform: rotate(90deg);
  827. }
  828. }
  829. }
  830. // 内容滚动区域
  831. .content-scroll {
  832. margin-top: 88rpx;
  833. height: calc(100vh - 88rpx - 128rpx);
  834. padding-bottom: 16rpx;
  835. // 动态详情
  836. .dynamic-detail {
  837. background: #FFFFFF;
  838. padding: 24rpx;
  839. margin: 12rpx 12rpx 10rpx 12rpx;
  840. border-radius: 20rpx;
  841. box-shadow: 0 6rpx 18rpx rgba(233, 30, 99, 0.08);
  842. border: 2rpx solid rgba(255, 182, 193, 0.25);
  843. position: relative;
  844. overflow: hidden;
  845. .user-section {
  846. display: flex;
  847. align-items: center;
  848. margin-bottom: 30rpx;
  849. .avatar {
  850. width: 80rpx;
  851. height: 80rpx;
  852. border-radius: 50%;
  853. margin-right: 20rpx;
  854. }
  855. .user-info {
  856. flex: 1;
  857. .nickname {
  858. display: block;
  859. font-size: 30rpx;
  860. font-weight: bold;
  861. color: #333333;
  862. margin-bottom: 8rpx;
  863. }
  864. .time {
  865. font-size: 24rpx;
  866. color: #999999;
  867. }
  868. }
  869. }
  870. .content-section {
  871. margin-bottom: 30rpx;
  872. .content-text {
  873. font-size: 30rpx;
  874. line-height: 1.8;
  875. color: #333333;
  876. word-wrap: break-word;
  877. }
  878. }
  879. .media-section {
  880. margin-bottom: 16rpx;
  881. .image-grid {
  882. display: grid;
  883. grid-template-columns: repeat(3, 1fr);
  884. gap: 10rpx;
  885. .media-image {
  886. width: 100%;
  887. height: 200rpx;
  888. border-radius: 12rpx;
  889. }
  890. }
  891. }
  892. .stats-section {
  893. display: flex;
  894. padding-top: 30rpx;
  895. border-top: 1rpx solid #F0F0F0;
  896. .stat-item {
  897. flex: 1;
  898. text-align: center;
  899. font-size: 26rpx;
  900. color: #666666;
  901. }
  902. }
  903. }
  904. // 评论区域
  905. .comment-section {
  906. background: #FFFFFF;
  907. padding: 24rpx;
  908. margin: 0 12rpx 12rpx 12rpx;
  909. border-radius: 20rpx;
  910. box-shadow: 0 6rpx 18rpx rgba(233, 30, 99, 0.08);
  911. border: 2rpx solid rgba(255, 182, 193, 0.25);
  912. position: relative;
  913. .section-title {
  914. display: flex;
  915. align-items: center;
  916. margin-bottom: 16rpx;
  917. .title-text {
  918. font-size: 32rpx;
  919. font-weight: bold;
  920. color: #333333;
  921. margin-right: 10rpx;
  922. }
  923. .title-count {
  924. font-size: 24rpx;
  925. color: #999999;
  926. }
  927. }
  928. .comment-list {
  929. .comment-item {
  930. display: flex;
  931. margin-bottom: 18rpx;
  932. padding-bottom: 18rpx;
  933. border-bottom: 1rpx solid #f8f8f8;
  934. &:last-child {
  935. border-bottom: none;
  936. margin-bottom: 0;
  937. padding-bottom: 0;
  938. }
  939. .comment-avatar {
  940. width: 60rpx;
  941. height: 60rpx;
  942. border-radius: 50%;
  943. margin-right: 20rpx;
  944. flex-shrink: 0;
  945. }
  946. .comment-content {
  947. flex: 1;
  948. .comment-nickname {
  949. display: block;
  950. font-size: 26rpx;
  951. color: #666666;
  952. margin-bottom: 6rpx;
  953. font-weight: 500;
  954. }
  955. .comment-text {
  956. display: block;
  957. font-size: 28rpx;
  958. line-height: 1.55;
  959. color: #333333;
  960. margin-bottom: 8rpx;
  961. }
  962. .comment-images {
  963. display: flex;
  964. flex-wrap: wrap;
  965. gap: 10rpx;
  966. margin: 12rpx 0;
  967. .comment-image {
  968. width: 150rpx;
  969. height: 150rpx;
  970. border-radius: 12rpx;
  971. overflow: hidden;
  972. }
  973. }
  974. // 子评论样式(核心优化)
  975. .reply-list {
  976. margin-top: 12rpx;
  977. margin-left: 20rpx;
  978. padding-left: 16rpx;
  979. border-left: 2rpx solid #f8f8f8;
  980. .reply-item {
  981. display: flex;
  982. align-items: flex-start;
  983. margin-bottom: 10rpx;
  984. padding: 10rpx;
  985. background: #fafafa;
  986. border-radius: 12rpx;
  987. .reply-avatar {
  988. width: 40rpx;
  989. height: 40rpx;
  990. }}
  991. .reply-info {
  992. flex: 1;
  993. min-width: 0; // 防止文字溢出
  994. .reply-nickname {
  995. font-size: 24rpx;
  996. color: #999;
  997. font-weight: 500;
  998. }
  999. .reply-text {
  1000. font-size: 26rpx;
  1001. color: #333;
  1002. line-height: 1.4;
  1003. margin: 4rpx 0;
  1004. }
  1005. .reply-time {
  1006. font-size: 20rpx;
  1007. color: #ccc;
  1008. display: block;
  1009. }
  1010. }
  1011. }
  1012. }
  1013. .comment-footer {
  1014. display: flex;
  1015. justify-content: space-between;
  1016. align-items: center;
  1017. margin-top: 12rpx;
  1018. .comment-time {
  1019. font-size: 22rpx;
  1020. color: #999;
  1021. }
  1022. .comment-actions {
  1023. display: flex;
  1024. gap: 20rpx;
  1025. .action {
  1026. font-size: 24rpx;
  1027. color: #666;
  1028. padding: 4rpx 8rpx;
  1029. border-radius: 8rpx;
  1030. transition: background-color 0.2s;
  1031. &:active {
  1032. background-color: #f0f0f0;
  1033. }
  1034. }
  1035. }
  1036. }
  1037. }
  1038. }
  1039. .empty-comment {
  1040. display: flex;
  1041. flex-direction: column;
  1042. align-items: center;
  1043. padding: 100rpx 0;
  1044. .feather-decoration {
  1045. display: flex;
  1046. gap: 16rpx;
  1047. margin-bottom: 20rpx;
  1048. .feather-small {
  1049. font-size: 40rpx;
  1050. opacity: 0.3;
  1051. }
  1052. }
  1053. .empty-icon {
  1054. font-size: 100rpx;
  1055. margin-bottom: 20rpx;
  1056. }
  1057. .empty-text {
  1058. font-size: 26rpx;
  1059. color: #999;
  1060. margin-bottom: 8rpx;
  1061. }
  1062. .empty-hint {
  1063. font-size: 22rpx;
  1064. color: #ccc;
  1065. }
  1066. }
  1067. }
  1068. }
  1069. // 更多菜单弹窗
  1070. .menu-popup {
  1071. position: fixed;
  1072. top: 0;
  1073. left: 0;
  1074. right: 0;
  1075. bottom: 0;
  1076. z-index: 9999;
  1077. .menu-mask {
  1078. position: absolute;
  1079. top: 0;
  1080. left: 0;
  1081. right: 0;
  1082. bottom: 0;
  1083. background-color: rgba(0, 0, 0, 0.5);
  1084. }
  1085. .menu-content {
  1086. position: absolute;
  1087. bottom: 0;
  1088. left: 0;
  1089. right: 0;
  1090. background-color: #FFFFFF;
  1091. border-radius: 30rpx 30rpx 0 0;
  1092. padding: 20rpx 30rpx;
  1093. padding-bottom: calc(20rpx + constant(safe-area-inset-bottom));
  1094. padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
  1095. animation: slideUp 0.3s ease;
  1096. .menu-item {
  1097. height: 100rpx;
  1098. display: flex;
  1099. align-items: center;
  1100. justify-content: center;
  1101. border-radius: 12rpx;
  1102. margin-bottom: 16rpx;
  1103. transition: background-color 0.3s ease;
  1104. &:not(.cancel) {
  1105. background: #FFF5F7;
  1106. &:active {
  1107. background: #FFE8EC;
  1108. }
  1109. }
  1110. &.cancel {
  1111. background: #F5F5F5;
  1112. margin-top: 8rpx;
  1113. &:active {
  1114. background: #EEEEEE;
  1115. }
  1116. }
  1117. .menu-icon {
  1118. font-size: 36rpx;
  1119. margin-right: 12rpx;
  1120. }
  1121. .menu-text {
  1122. font-size: 30rpx;
  1123. color: #333333;
  1124. font-weight: 500;
  1125. }
  1126. }
  1127. }
  1128. }
  1129. // 底部操作栏
  1130. .bottom-bar {
  1131. position: fixed;
  1132. bottom: 0;
  1133. left: 0;
  1134. right: 0;
  1135. background-color: #FFFFFF;
  1136. padding: 20rpx 30rpx;
  1137. display: flex;
  1138. align-items: center;
  1139. box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
  1140. padding-bottom: constant(safe-area-inset-bottom);
  1141. padding-bottom: env(safe-area-inset-bottom);
  1142. .input-area {
  1143. flex: 1;
  1144. height: 70rpx;
  1145. background-color: #F5F5F5;
  1146. border-radius: 35rpx;
  1147. padding: 0 30rpx;
  1148. display: flex;
  1149. align-items: center;
  1150. margin-right: 20rpx;
  1151. .love-prefix {
  1152. font-size: 28rpx;
  1153. margin-right: 8rpx;
  1154. }
  1155. .input-placeholder {
  1156. font-size: 26rpx;
  1157. color: #999999;
  1158. }
  1159. }
  1160. .action-buttons {
  1161. display: flex;
  1162. gap: 20rpx;
  1163. .action-btn {
  1164. display: flex;
  1165. flex-direction: column;
  1166. align-items: center;
  1167. padding: 12rpx 16rpx;
  1168. border-radius: 24rpx;
  1169. background: rgba(255, 182, 193, 0.1);
  1170. transition: all 0.3s ease;
  1171. &:active {
  1172. transform: scale(0.95);
  1173. background: rgba(233, 30, 99, 0.2);
  1174. }
  1175. .action-icon {
  1176. font-size: 48rpx;
  1177. margin-bottom: 4rpx;
  1178. &.active {
  1179. color: #E91E63;
  1180. animation: heartbeat 0.6s ease-in-out;
  1181. }
  1182. }
  1183. .action-label {
  1184. font-size: 22rpx;
  1185. color: #666;
  1186. font-weight: 500;
  1187. }
  1188. }
  1189. }
  1190. }
  1191. // 评论输入弹窗
  1192. .comment-popup {
  1193. position: fixed;
  1194. top: 0;
  1195. left: 0;
  1196. right: 0;
  1197. bottom: 0;
  1198. z-index: 9999;
  1199. .popup-mask {
  1200. position: absolute;
  1201. top: 0;
  1202. left: 0;
  1203. right: 0;
  1204. bottom: 0;
  1205. background-color: rgba(0, 0, 0, 0.5);
  1206. }
  1207. .popup-content {
  1208. position: absolute;
  1209. bottom: 0;
  1210. left: 0;
  1211. right: 0;
  1212. background-color: #FFFFFF;
  1213. border-radius: 30rpx 30rpx 0 0;
  1214. padding: 30rpx;
  1215. padding-bottom: calc(30rpx + constant(safe-area-inset-bottom));
  1216. padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
  1217. .comment-textarea {
  1218. width: 100%;
  1219. min-height: 200rpx;
  1220. max-height: 400rpx;
  1221. padding: 20rpx;
  1222. background-color: #F5F5F5;
  1223. border-radius: 12rpx;
  1224. font-size: 28rpx;
  1225. line-height: 1.6;
  1226. margin-bottom: 20rpx;
  1227. border: none;
  1228. outline: none;
  1229. }
  1230. .image-section {
  1231. margin-bottom: 20rpx;
  1232. .image-list {
  1233. display: flex;
  1234. flex-wrap: wrap;
  1235. gap: 16rpx;
  1236. }
  1237. .image-item {
  1238. position: relative;
  1239. width: 120rpx;
  1240. height: 120rpx;
  1241. border-radius: 12rpx;
  1242. overflow: hidden;
  1243. .preview-image {
  1244. width: 100%;
  1245. height: 100%;
  1246. }
  1247. .remove-image {
  1248. position: absolute;
  1249. top: 8rpx;
  1250. right: 8rpx;
  1251. width: 32rpx;
  1252. height: 32rpx;
  1253. background: rgba(0,0,0,0.6);
  1254. color: white;
  1255. border-radius: 50%;
  1256. display: flex;
  1257. align-items: center;
  1258. justify-content: center;
  1259. font-size: 24rpx;
  1260. font-weight: bold;
  1261. }
  1262. }
  1263. .add-image {
  1264. width: 120rpx;
  1265. height: 120rpx;
  1266. background: #F5F5F5;
  1267. border: 2rpx dashed #CCCCCC;
  1268. border-radius: 12rpx;
  1269. display: flex;
  1270. flex-direction: column;
  1271. align-items: center;
  1272. justify-content: center;
  1273. .add-icon {
  1274. font-size: 32rpx;
  1275. margin-bottom: 8rpx;
  1276. }
  1277. .add-text {
  1278. font-size: 20rpx;
  1279. color: #999999;
  1280. }
  1281. }
  1282. }
  1283. .popup-footer {
  1284. display: flex;
  1285. justify-content: space-between;
  1286. align-items: center;
  1287. .char-count {
  1288. font-size: 24rpx;
  1289. color: #999999;
  1290. }
  1291. .submit-btn {
  1292. padding: 15rpx 50rpx;
  1293. background: linear-gradient(135deg, #E91E63 0%, #FF6B9D 100%);
  1294. color: #FFFFFF;
  1295. border: none;
  1296. border-radius: 50rpx;
  1297. font-size: 28rpx;
  1298. transition: transform 0.2s;
  1299. &:active {
  1300. transform: scale(0.95);
  1301. }
  1302. }
  1303. }
  1304. }
  1305. }
  1306. }
  1307. // 全局动画
  1308. @keyframes slideUp {
  1309. from {
  1310. transform: translateY(100%);
  1311. opacity: 0;
  1312. }
  1313. to {
  1314. transform: translateY(0);
  1315. opacity: 1;
  1316. }
  1317. }
  1318. @keyframes heartbeat {
  1319. 0%, 100% {
  1320. transform: scale(1);
  1321. }
  1322. 50% {
  1323. transform: scale(1.2);
  1324. }
  1325. }
  1326. @keyframes float {
  1327. 0%, 100% {
  1328. transform: translate(-50%, -50%) rotate(25deg);
  1329. }
  1330. 50% {
  1331. transform: translate(-50%, -60%) rotate(25deg);
  1332. }
  1333. }
  1334. </style>