detail.vue 37 KB

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