detail.vue 36 KB

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