notice.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. <template>
  2. <view class="notice-page">
  3. <image v-if="!hasNotify" class="bg" src="/static/images/deviceunlogin.png" mode="aspectFit" />
  4. <view v-else class="notify-container">
  5. <view
  6. v-if="studentNotifyUsers.length"
  7. class="notify-section section-student"
  8. :class="{ 'with-divider': parentUnreadUsers.length }"
  9. :style="{ height: sectionHeights.student + 'px' }"
  10. >
  11. <view
  12. v-for="(item, index) in studentLayout.positions"
  13. :key="'xy-' + item.id + '_' + index"
  14. class="notify-card student-card"
  15. :style="{
  16. left: item.left + 'px',
  17. top: item.top + 'px',
  18. width: item.width + 'px',
  19. height: item.height + 'px',
  20. gap: item.gap + 'px'
  21. }"
  22. >
  23. <image
  24. class="avatar student-avatar"
  25. :src="item.avatar"
  26. mode="aspectFill"
  27. :style="{ width: item.avatarWidth + 'px', height: item.avatarHeight + 'px' }"
  28. />
  29. <view class="name" :style="{ fontSize: item.fontSize + 'px' }">{{ item.name }}</view>
  30. </view>
  31. </view>
  32. <view
  33. v-if="parentUnreadUsers.length"
  34. class="notify-section section-parent"
  35. :style="{ height: sectionHeights.parent + 'px' }"
  36. >
  37. <view
  38. v-for="(item, index) in parentLayout.positions"
  39. :key="'jz-' + item.id + '_' + index"
  40. class="notify-card parent-card"
  41. :style="{
  42. left: item.left + 'px',
  43. top: item.top + 'px',
  44. width: item.width + 'px',
  45. height: item.height + 'px',
  46. gap: item.gap + 'px'
  47. }"
  48. >
  49. <image
  50. class="avatar parent-avatar"
  51. :src="item.avatar"
  52. mode="aspectFill"
  53. :style="{ width: item.avatarWidth + 'px', height: item.avatarHeight + 'px' }"
  54. />
  55. <view class="name" :style="{ fontSize: item.fontSize + 'px' }">{{ item.name }}</view>
  56. </view>
  57. </view>
  58. </view>
  59. <view class="footer">
  60. <view class="footer-text">
  61. <view class="triangles">
  62. <view class="triangle" />
  63. <view class="triangle" />
  64. <view class="triangle" />
  65. </view>
  66. 请刷卡登录查看留言
  67. </view>
  68. </view>
  69. </view>
  70. </template>
  71. <script setup>
  72. import { computed, ref } from 'vue'
  73. import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
  74. import websocketService from '@/utils/websocket'
  75. import { deviceApi } from '@/api/device'
  76. import { getImageUrl } from '@/utils/util'
  77. const DEFAULT_AVATAR = '/static/logo.png'
  78. const USE_MOCK_WHEN_EMPTY = true
  79. const studentNotifyUsers = ref([])
  80. const parentUnreadUsers = ref([])
  81. const deviceSn = ref('')
  82. const noticeBootstrapped = ref(false)
  83. const skipFirstOnShow = ref(false)
  84. const wsUnsubscribers = []
  85. const totalNotifyCount = computed(() => studentNotifyUsers.value.length + parentUnreadUsers.value.length)
  86. const hasNotify = computed(() => totalNotifyCount.value > 0)
  87. // 竖屏设备目标尺寸(px)
  88. const SCREEN_WIDTH = 800
  89. const SCREEN_HEIGHT = 1280
  90. const FOOTER_HEIGHT = 50 * (SCREEN_WIDTH / 750)
  91. const HORIZONTAL_PADDING = 10 * (SCREEN_WIDTH / 750) * 2
  92. const VERTICAL_PADDING = 10 * (SCREEN_WIDTH / 750) * 2
  93. const AVAILABLE_WIDTH = SCREEN_WIDTH - HORIZONTAL_PADDING
  94. const AVAILABLE_HEIGHT = (SCREEN_HEIGHT - FOOTER_HEIGHT) - VERTICAL_PADDING
  95. const CARD_RATIO = 49 / 40
  96. const MIN_SECTION_HEIGHT = 140
  97. const SECTION_INNER_PADDING_Y = 8
  98. function toDisplayImage(path) {
  99. if (!path) return DEFAULT_AVATAR
  100. if (typeof path !== 'string') return DEFAULT_AVATAR
  101. if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:') || path.startsWith('/')) {
  102. return path
  103. }
  104. return getImageUrl(path)
  105. }
  106. function toDisplayName(value, fallback = '未命名') {
  107. const name = String(value || '').trim()
  108. return name || fallback
  109. }
  110. function formatStudentList(list = []) {
  111. return list.map((item = {}, index) => ({
  112. id: item.ryid || item.xyid || `xy-${index}`,
  113. name: toDisplayName(item.xm, '学员'),
  114. avatar: toDisplayImage(item.zjzwj)
  115. }))
  116. }
  117. function formatParentList(list = []) {
  118. return list.map((item = {}, index) => ({
  119. id: item.ryid || item.jzid || `jz-${index}`,
  120. name: toDisplayName(item.xm, '家长'),
  121. avatar: toDisplayImage(item.yszwj)
  122. }))
  123. }
  124. function createMockRefreshPayload() {
  125. return {
  126. xyList: [
  127. { ryid: 'xy-1', xm: '张珊', zjzwj: '/static/logo.png' },
  128. { ryid: 'xy-2', xm: '王舞', zjzwj: '/static/logo.png' },
  129. { ryid: 'xy-3', xm: '李丽思', zjzwj: '/static/logo.png' }
  130. ],
  131. jzList: [
  132. { ryid: 'jz-1', xm: '张珊', yszwj: '/static/logo.png' },
  133. { ryid: 'jz-2', xm: '王舞', yszwj: '/static/logo.png' },
  134. { ryid: 'jz-3', xm: '李丽思', yszwj: '/static/logo.png' },
  135. { ryid: 'jz-4', xm: '张珊二', yszwj: '/static/logo.png' }
  136. ]
  137. }
  138. }
  139. function extractRefreshPayload(data = {}) {
  140. const direct = data && typeof data === 'object' ? data : {}
  141. if (Array.isArray(direct.xyList) || Array.isArray(direct.jzList)) {
  142. return direct
  143. }
  144. const ssData = direct.ssData && typeof direct.ssData === 'object' ? direct.ssData : null
  145. if (ssData && (Array.isArray(ssData.xyList) || Array.isArray(ssData.jzList))) {
  146. return ssData
  147. }
  148. const requestData = direct.request && typeof direct.request === 'object' ? direct.request : null
  149. if (requestData && (Array.isArray(requestData.xyList) || Array.isArray(requestData.jzList))) {
  150. return requestData
  151. }
  152. return {}
  153. }
  154. function calculateOptimalGrid(count, areaWidth, areaHeight, cardRatio = CARD_RATIO) {
  155. if (!count || areaWidth <= 0 || areaHeight <= 0) return { rows: 0, cols: 0 }
  156. if (count === 1) return { rows: 1, cols: 1 }
  157. const targetRatio = areaHeight / areaWidth
  158. let bestRows = 1
  159. let bestCols = count
  160. let bestScore = Number.POSITIVE_INFINITY
  161. for (let rows = 1; rows <= count; rows++) {
  162. const cols = Math.ceil(count / rows)
  163. const capacity = rows * cols
  164. const wasteRatio = (capacity - count) / count
  165. const maxWidth = areaWidth / cols
  166. const maxHeight = areaHeight / rows / cardRatio
  167. const isWidthLimited = maxWidth < maxHeight
  168. const ratioDiff = Math.abs((rows / cols) - targetRatio)
  169. const widthPenalty = isWidthLimited ? 0 : 2.0
  170. const score = widthPenalty + wasteRatio * 0.5 + ratioDiff * 0.3
  171. if (score < bestScore) {
  172. bestScore = score
  173. bestRows = rows
  174. bestCols = cols
  175. }
  176. }
  177. return { rows: bestRows, cols: bestCols }
  178. }
  179. function buildSectionLayout(users = [], sectionHeight = 0, options = {}) {
  180. const list = Array.isArray(users) ? users : []
  181. const count = list.length
  182. if (!count || sectionHeight <= 0) {
  183. return { positions: [] }
  184. }
  185. const avatarScale = options.avatarScale || 0.55
  186. const avatarRatio = options.avatarRatio || 1 // width / height
  187. const maxSingleCardWidth = options.maxSingleCardWidth || 600
  188. const baseCols = options.baseCols || 4
  189. const areaWidth = AVAILABLE_WIDTH
  190. const areaHeight = Math.max(0, sectionHeight - SECTION_INNER_PADDING_Y * 2)
  191. if (areaWidth <= 0 || areaHeight <= 0) {
  192. return { positions: [] }
  193. }
  194. const { rows, cols } = calculateOptimalGrid(count, areaWidth, areaHeight, CARD_RATIO)
  195. if (!rows || !cols) return { positions: [] }
  196. const maxWidth = areaWidth / cols
  197. const maxHeight = areaHeight / rows / CARD_RATIO
  198. let cardWidth = Math.min(maxWidth, maxHeight)
  199. if (count === 1) {
  200. cardWidth = Math.min(cardWidth, maxSingleCardWidth)
  201. }
  202. const cardHeight = cardWidth * CARD_RATIO
  203. const totalWidth = cols * cardWidth
  204. const totalHeight = rows * cardHeight
  205. const offsetX = (areaWidth - totalWidth) / 2 + HORIZONTAL_PADDING / 2
  206. const offsetY = (areaHeight - totalHeight) / 2 + SECTION_INNER_PADDING_Y
  207. const baseCardWidth = AVAILABLE_WIDTH / baseCols
  208. const baseFontSize = 30 * (SCREEN_WIDTH / 750)
  209. const fontSize = Math.max(12, (cardWidth / baseCardWidth) * baseFontSize)
  210. const gap = Math.max(6, cardWidth * 0.06)
  211. const avatarWidth = cardWidth * avatarScale
  212. const avatarHeight = avatarWidth / avatarRatio
  213. const positions = list.map((item, index) => {
  214. const row = Math.floor(index / cols)
  215. const col = index % cols
  216. const itemsInCurrentRow = Math.min(cols, count - row * cols)
  217. const rowCenterOffset = itemsInCurrentRow < cols
  218. ? (cols - itemsInCurrentRow) * cardWidth / 2
  219. : 0
  220. return {
  221. ...item,
  222. left: offsetX + col * cardWidth + rowCenterOffset,
  223. top: offsetY + row * cardHeight,
  224. width: cardWidth,
  225. height: cardHeight,
  226. fontSize,
  227. gap,
  228. avatarWidth,
  229. avatarHeight
  230. }
  231. })
  232. return { positions }
  233. }
  234. const sectionHeights = computed(() => {
  235. const studentCount = studentNotifyUsers.value.length
  236. const parentCount = parentUnreadUsers.value.length
  237. if (!studentCount && !parentCount) {
  238. return { student: 0, parent: 0 }
  239. }
  240. if (!studentCount) {
  241. return { student: 0, parent: AVAILABLE_HEIGHT }
  242. }
  243. if (!parentCount) {
  244. return { student: AVAILABLE_HEIGHT, parent: 0 }
  245. }
  246. let studentHeight = Math.round(AVAILABLE_HEIGHT * 0.7)
  247. studentHeight = Math.max(MIN_SECTION_HEIGHT, Math.min(studentHeight, AVAILABLE_HEIGHT - MIN_SECTION_HEIGHT))
  248. return {
  249. student: studentHeight,
  250. parent: AVAILABLE_HEIGHT - studentHeight
  251. }
  252. })
  253. const studentLayout = computed(() => buildSectionLayout(studentNotifyUsers.value, sectionHeights.value.student, {
  254. avatarScale: 0.56,
  255. avatarRatio: 68 / 100,
  256. maxSingleCardWidth: 260
  257. }))
  258. const parentLayout = computed(() => buildSectionLayout(parentUnreadUsers.value, sectionHeights.value.parent, {
  259. avatarScale: 0.48,
  260. avatarRatio: 1,
  261. maxSingleCardWidth: 220
  262. }))
  263. function resolveDeviceSn(options = {}) {
  264. if (options && options.sn) return String(options.sn)
  265. let userInfo = uni.getStorageSync('userInfo') || {}
  266. if (typeof userInfo === 'string') {
  267. try {
  268. userInfo = JSON.parse(userInfo)
  269. } catch (error) {
  270. userInfo = {}
  271. }
  272. }
  273. if (userInfo.devId) return String(userInfo.devId)
  274. const deviceInfo = uni.getStorageSync('deviceInfo') || {}
  275. if (deviceInfo.deviceId) return String(deviceInfo.deviceId)
  276. return 'ssDevId_a'
  277. }
  278. async function refreshNoticeUsers() {
  279. try {
  280. const result = await deviceApi.tel_refreshLogin()
  281. const payload = extractRefreshPayload(result?.data || {})
  282. let xyList = Array.isArray(payload.xyList) ? payload.xyList : []
  283. let jzList = Array.isArray(payload.jzList) ? payload.jzList : []
  284. if (!xyList.length && !jzList.length && USE_MOCK_WHEN_EMPTY) {
  285. const mock = createMockRefreshPayload()
  286. xyList = mock.xyList
  287. jzList = mock.jzList
  288. }
  289. studentNotifyUsers.value = formatStudentList(xyList)
  290. parentUnreadUsers.value = formatParentList(jzList)
  291. } catch (error) {
  292. console.error('刷新电话机首页留言失败:', error)
  293. if (USE_MOCK_WHEN_EMPTY) {
  294. const mock = createMockRefreshPayload()
  295. studentNotifyUsers.value = formatStudentList(mock.xyList)
  296. parentUnreadUsers.value = formatParentList(mock.jzList)
  297. return
  298. }
  299. studentNotifyUsers.value = []
  300. parentUnreadUsers.value = []
  301. }
  302. }
  303. function bindNoticeSocketListeners() {
  304. if (wsUnsubscribers.length) return
  305. const offRefresh = websocketService.on('cmd:51', async () => {
  306. await refreshNoticeUsers()
  307. uni.$emit('device-message-refresh')
  308. })
  309. wsUnsubscribers.push(offRefresh)
  310. }
  311. async function ensureNoticeSocketConnected() {
  312. if (!deviceSn.value) return
  313. bindNoticeSocketListeners()
  314. try {
  315. await websocketService.ensureConnected({
  316. role: 'device',
  317. ssDevId: deviceSn.value,
  318. heartbeat: true,
  319. autoReconnect: true,
  320. maxReconnectAttempts: 5
  321. })
  322. } catch (error) {
  323. console.error('notice 建立设备 websocket 失败:', error)
  324. }
  325. }
  326. onLoad(async (options) => {
  327. deviceSn.value = resolveDeviceSn(options || {})
  328. noticeBootstrapped.value = true
  329. skipFirstOnShow.value = true
  330. await refreshNoticeUsers()
  331. await ensureNoticeSocketConnected()
  332. })
  333. onShow(async () => {
  334. if (!noticeBootstrapped.value) return
  335. if (skipFirstOnShow.value) {
  336. skipFirstOnShow.value = false
  337. return
  338. }
  339. if (!deviceSn.value) {
  340. deviceSn.value = resolveDeviceSn({})
  341. }
  342. await refreshNoticeUsers()
  343. await ensureNoticeSocketConnected()
  344. })
  345. onUnload(() => {
  346. wsUnsubscribers.forEach((off) => {
  347. if (typeof off === 'function') off()
  348. })
  349. wsUnsubscribers.length = 0
  350. })
  351. </script>
  352. <style>
  353. .notice-page {
  354. width: 100vw;
  355. height: 100vh;
  356. background: #000;
  357. overflow: hidden;
  358. padding-bottom: 50rpx;
  359. box-sizing: border-box;
  360. }
  361. .bg {
  362. width: 100%;
  363. height: 100%;
  364. }
  365. .notify-container {
  366. width: 100%;
  367. height: 100%;
  368. display: flex;
  369. flex-direction: column;
  370. }
  371. .notify-section {
  372. width: 100%;
  373. position: relative;
  374. overflow: hidden;
  375. }
  376. .section-student {
  377. background: #fff;
  378. }
  379. .section-student.with-divider {
  380. border-bottom: 1px solid #b5b5b5;
  381. }
  382. .section-parent {
  383. background: #d9d9d9;
  384. }
  385. .notify-card {
  386. position: absolute;
  387. box-sizing: border-box;
  388. display: flex;
  389. flex-direction: column;
  390. align-items: center;
  391. justify-content: center;
  392. border: 1px solid #b3b3b3;
  393. }
  394. .student-card {
  395. background: #fff;
  396. }
  397. .parent-card {
  398. background: #d9d9d9;
  399. }
  400. .avatar {
  401. box-sizing: border-box;
  402. display: block;
  403. background: #f2f2f2;
  404. }
  405. .student-avatar {
  406. border-radius: 4rpx;
  407. border: 1px solid #e6e6e6;
  408. }
  409. .parent-avatar {
  410. border-radius: 50%;
  411. border: 2px solid #fff;
  412. }
  413. .name {
  414. color: #000;
  415. font-weight: 600;
  416. text-align: center;
  417. word-break: break-all;
  418. line-height: 1.25;
  419. }
  420. .footer {
  421. position: fixed;
  422. left: 0;
  423. right: 0;
  424. bottom: 0;
  425. width: 100%;
  426. height: 50rpx;
  427. background: #b2b2b2;
  428. display: flex;
  429. align-items: center;
  430. justify-content: center;
  431. }
  432. .footer-text {
  433. color: #000;
  434. font-size: 26rpx;
  435. line-height: 1;
  436. display: inline-flex;
  437. align-items: center;
  438. }
  439. .triangles {
  440. display: inline-flex;
  441. align-items: center;
  442. gap: 6rpx;
  443. margin-right: 12rpx;
  444. }
  445. .triangle {
  446. width: 0;
  447. height: 0;
  448. border-top: 10rpx solid transparent;
  449. border-bottom: 10rpx solid transparent;
  450. border-left: 14rpx solid #000;
  451. }
  452. </style>