notice.vue 13 KB

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