detail.vue 35 KB

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