index.vue 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. <template>
  2. <view class="message-page">
  3. <!-- 自定义顶栏:头像 + 组织名 + 搜索 -->
  4. <view class="custom-header">
  5. <view class="header-left" @click="onAvatarClick">
  6. <UserAvatar
  7. :name="currentUser.name"
  8. :id="currentUser.id"
  9. :src="currentUser.avatar"
  10. :size="80"
  11. unit="rpx"
  12. />
  13. <view class="org-info">
  14. <text class="user-name">{{ currentUser.name || '未设置' }}</text>
  15. <text class="org-name">{{ currentUser.orgName || '' }}</text>
  16. </view>
  17. </view>
  18. <view class="header-right">
  19. <view class="icon-btn" @click="onSearch">
  20. <image class="icon-img" src="/static/icons/search.svg" mode="aspectFit" />
  21. </view>
  22. </view>
  23. </view>
  24. <!-- 消息列表 -->
  25. <scroll-view
  26. class="message-list"
  27. scroll-y
  28. refresher-enabled
  29. :refresher-triggered="refresherTriggered"
  30. @refresherrefresh="onRefresh"
  31. >
  32. <view v-if="loadingContacts" class="empty-tip">加载中...</view>
  33. <view v-else-if="!messageList || messageList.length === 0" class="empty-tip" @click="goLogin">暂无会话,点击去登录</view>
  34. <view
  35. v-else
  36. class="message-item"
  37. v-for="item in messageList"
  38. :key="item.id"
  39. @click="openChat(item)"
  40. >
  41. <view class="item-left">
  42. <view class="avatar-wrap">
  43. <SystemAvatar
  44. v-if="item.is_system || (item.app_id != null && item.app_id !== '')"
  45. :name="item.app_name || item.title"
  46. :size="96"
  47. unit="rpx"
  48. />
  49. <UserAvatar
  50. v-else
  51. :name="item.title"
  52. :id="item.id"
  53. :src="item.avatar"
  54. :size="96"
  55. unit="rpx"
  56. />
  57. <view v-if="item.unread" class="badge">{{ item.unread > 99 ? '99+' : item.unread }}</view>
  58. </view>
  59. <view class="item-content">
  60. <view class="item-row">
  61. <text class="item-title">{{ item.app_name || item.title }}</text>
  62. <text class="item-time">{{ item.time }}</text>
  63. </view>
  64. <text class="item-desc">{{ item.lastMessage }}</text>
  65. </view>
  66. </view>
  67. </view>
  68. </scroll-view>
  69. </view>
  70. </template>
  71. <script>
  72. import { computed, onMounted, ref } from 'vue'
  73. import UserAvatar from '../../components/UserAvatar.vue'
  74. import SystemAvatar from '../../components/SystemAvatar.vue'
  75. import { useContacts } from '../../composables/useContacts'
  76. import { useWebSocket } from '../../composables/useWebSocket'
  77. import { chatStore } from '../../store/chat'
  78. import { setupAppNotifications } from '../../utils/notificationSetup'
  79. const USER_KEY = 'current_user'
  80. function formatTime(str) {
  81. if (!str) return ''
  82. const d = new Date(str)
  83. if (isNaN(d.getTime())) return str
  84. const now = new Date()
  85. const isToday = d.toDateString() === now.toDateString()
  86. if (isToday) return d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0')
  87. const yesterday = new Date(now)
  88. yesterday.setDate(yesterday.getDate() - 1)
  89. if (d.toDateString() === yesterday.toDateString()) return '昨天'
  90. return (d.getMonth() + 1) + '月' + d.getDate() + '日'
  91. }
  92. export default {
  93. components: { UserAvatar, SystemAvatar },
  94. setup() {
  95. const { fetchContacts } = useContacts()
  96. const { connect } = useWebSocket(fetchContacts)
  97. const currentUser = ref({ name: '', id: '', avatar: '', orgName: '' })
  98. const refresherTriggered = ref(false)
  99. function loadCurrentUser() {
  100. try {
  101. const raw = uni.getStorageSync(USER_KEY)
  102. if (raw && typeof raw === 'object') {
  103. currentUser.value = {
  104. name: raw.name || raw.nickname || '',
  105. id: String(raw.id ?? raw.user_id ?? ''),
  106. id: String(raw.id ?? raw.user_id ?? ''),
  107. avatar: raw.avatar || raw.avatar_url || '',
  108. orgName: raw.org_name || raw.orgName || ''
  109. }
  110. }
  111. } catch (e) {}
  112. }
  113. const messageList = computed(() => {
  114. // 显式依赖 unreadByContactId,保证未读变化时角标会更新
  115. void chatStore.unreadByContactId
  116. return (chatStore.contacts || []).map((c) => ({
  117. ...c,
  118. time: formatTime(c.time),
  119. unread: chatStore.getUnread(c.id || c.user_id)
  120. }))
  121. })
  122. function openChat(item) {
  123. const id = item.user_id ?? item.id
  124. uni.navigateTo({ url: '/pages/chat/index?otherUserId=' + encodeURIComponent(id) })
  125. }
  126. async function onRefresh() {
  127. refresherTriggered.value = true
  128. try {
  129. await fetchContacts()
  130. chatStore.updateTabBarUnreadBadge()
  131. } finally {
  132. refresherTriggered.value = false
  133. }
  134. }
  135. onMounted(() => {
  136. loadCurrentUser()
  137. fetchContacts()
  138. connect()
  139. // #ifdef APP-PLUS
  140. setupAppNotifications()
  141. // #endif
  142. })
  143. const loadingContacts = computed(() => chatStore.loadingContacts)
  144. function goLogin() {
  145. uni.reLaunch({ url: '/pages/login/index' })
  146. }
  147. return {
  148. messageList,
  149. loadingContacts,
  150. fetchContacts,
  151. openChat,
  152. goLogin,
  153. currentUser,
  154. loadCurrentUser,
  155. refresherTriggered,
  156. onRefresh
  157. }
  158. },
  159. onShow() {
  160. if (this.loadCurrentUser) this.loadCurrentUser()
  161. if (this.fetchContacts) this.fetchContacts()
  162. chatStore.updateTabBarUnreadBadge()
  163. },
  164. onTabItemTap() {
  165. if (this.fetchContacts) this.fetchContacts()
  166. chatStore.updateTabBarUnreadBadge()
  167. },
  168. methods: {
  169. onAvatarClick() {
  170. uni.navigateTo({ url: '/pages/profile/index' })
  171. },
  172. onSearch() {
  173. uni.navigateTo({ url: '/pages/search-center/index' })
  174. }
  175. }
  176. }
  177. </script>
  178. <style scoped>
  179. .message-page {
  180. /* 高度占满视口,不再手动减 tabBar,高度保持 100vh 以保证 scroll-view 有明确高度 */
  181. height: 100vh;
  182. display: flex;
  183. flex-direction: column;
  184. background: #fff;
  185. }
  186. /* 顶部安全区:88rpx 为无安全区时的最小间距(安卓等),max 保证刘海/状态栏下也足够 */
  187. .custom-header {
  188. display: flex;
  189. align-items: center;
  190. justify-content: space-between;
  191. padding: 24rpx 24rpx 24rpx 32rpx;
  192. padding-top: 88rpx;
  193. padding-top: max(88rpx, calc(24rpx + constant(safe-area-inset-top)));
  194. padding-top: max(88rpx, calc(24rpx + env(safe-area-inset-top)));
  195. background: #fff;
  196. border-bottom: 1rpx solid #eee;
  197. }
  198. .header-left {
  199. display: flex;
  200. align-items: center;
  201. flex: 1;
  202. min-width: 0;
  203. }
  204. .avatar-wrap :deep(.user-avatar) {
  205. flex-shrink: 0;
  206. }
  207. .org-info {
  208. margin-left: 24rpx;
  209. display: flex;
  210. flex-direction: column;
  211. min-width: 0;
  212. }
  213. .user-name {
  214. font-size: 32rpx;
  215. font-weight: 600;
  216. color: #333;
  217. }
  218. .org-name {
  219. font-size: 24rpx;
  220. color: #999;
  221. margin-top: 4rpx;
  222. }
  223. .header-right {
  224. display: flex;
  225. align-items: center;
  226. gap: 24rpx;
  227. }
  228. .icon-btn {
  229. width: 64rpx;
  230. height: 64rpx;
  231. display: flex;
  232. align-items: center;
  233. justify-content: center;
  234. }
  235. .icon-img {
  236. width: 44rpx;
  237. height: 44rpx;
  238. opacity: 0.85;
  239. }
  240. .message-list {
  241. flex: 1;
  242. min-height: 0;
  243. height: 0;
  244. /* 使用框架提供的 tabBar 高度变量,为 H5 端等留出底部空间,避免与 tabBar 重叠 */
  245. padding-bottom: var(--window-bottom, 50px);
  246. box-sizing: border-box;
  247. }
  248. .empty-tip {
  249. padding: 80rpx 32rpx;
  250. text-align: center;
  251. font-size: 28rpx;
  252. color: #999;
  253. }
  254. .message-item {
  255. padding: 28rpx 32rpx;
  256. border-bottom: 1rpx solid #f0f0f0;
  257. }
  258. .item-left {
  259. display: flex;
  260. align-items: flex-start;
  261. }
  262. .avatar-wrap {
  263. position: relative;
  264. flex-shrink: 0;
  265. }
  266. .badge {
  267. position: absolute;
  268. top: -8rpx;
  269. right: -8rpx;
  270. min-width: 32rpx;
  271. height: 32rpx;
  272. line-height: 32rpx;
  273. padding: 0 8rpx;
  274. font-size: 20rpx;
  275. color: #fff;
  276. background: #f5222d;
  277. border-radius: 16rpx;
  278. text-align: center;
  279. }
  280. .item-content {
  281. flex: 1;
  282. margin-left: 24rpx;
  283. min-width: 0;
  284. }
  285. .item-row {
  286. display: flex;
  287. align-items: center;
  288. justify-content: space-between;
  289. margin-bottom: 8rpx;
  290. }
  291. .item-title {
  292. font-size: 30rpx;
  293. color: #333;
  294. flex: 1;
  295. overflow: hidden;
  296. text-overflow: ellipsis;
  297. white-space: nowrap;
  298. }
  299. .item-time {
  300. font-size: 24rpx;
  301. color: #999;
  302. flex-shrink: 0;
  303. margin-left: 16rpx;
  304. }
  305. .item-desc {
  306. font-size: 26rpx;
  307. color: #999;
  308. overflow: hidden;
  309. text-overflow: ellipsis;
  310. white-space: nowrap;
  311. display: block;
  312. }
  313. </style>