ruhuxu 4 天之前
父節點
當前提交
4a48152dac
共有 9 個文件被更改,包括 793 次插入111 次删除
  1. 22 3
      api/device.js
  2. 3 3
      api/user.js
  3. 2 2
      config/env.js
  4. 2 2
      pages/common/webview.vue
  5. 76 37
      pages/device/index.vue
  6. 24 11
      pages/device/notice.vue
  7. 16 7
      pages/main/index.vue
  8. 575 40
      pages/parent/message.vue
  9. 73 6
      utils/websocket.js

+ 22 - 3
api/device.js

@@ -22,7 +22,7 @@ export const deviceApi = {
    */
   login(sn, cardNo) {
     return deviceRequest.get(
-      `/service?ssServ=ss.login&devId=${sn}&cardNo=${cardNo}&sbmc=${sn}`,
+      `/service?ssServ=ssLogin&devId=${sn}&cardNo=${cardNo}&sbmc=${sn}`,
       {},
       {
         loading: { title: '设备登录中...' },
@@ -33,6 +33,24 @@ export const deviceApi = {
     )
   },
 
+  /**
+   * 设备退出登录
+   * @returns {Promise}
+   */
+  ssExit() {
+    return deviceRequest.post(
+      `/service?ssServ=ssExit`,
+      {},
+      {
+        loading: false,
+        formData: true,
+        request: {
+          timeout: 15000
+        }
+      }
+    )
+  },
+
   /**
    * 查询家长信息(联系人)
    * @returns {Promise} 返回家长信息
@@ -139,9 +157,10 @@ export const deviceApi = {
    * }
    */
   grfw_chkGrfw(grfwxmm) {
+    const code = String(grfwxmm || '').trim()
     return deviceRequest.post(
-      `/service?ssServ=grfw_chkGrfw`,
-      { grfwxmm },
+      `/service?ssServ=grfw_chkGrfw&grfwxmm=${encodeURIComponent(code)}`,
+      { grfwxmm: code },
       {
         loading: false,
         formData: true

+ 3 - 3
api/user.js

@@ -12,7 +12,7 @@ export const userApi = {
     uni.setStorageSync("deviceInfo", newDeviceInfo)
 
     const promise = request.post(
-      `/service?ssServ=ss.login&yhm=${data.yhm}&mm=${data.mm}&wdConfirmationCaptchaService=0&wechatCode=${data.wechatCode}`,
+      `/service?ssServ=ssLogin&yhm=${data.yhm}&mm=${data.mm}&wdConfirmationCaptchaService=0&wechatCode=${data.wechatCode}`,
       data,
       {
         loading: false, // 禁用全局 loading,由登录页面自己控制
@@ -57,7 +57,7 @@ export const userApi = {
   // 自动登录 - 静默登录,不显示 loading
   autoLogin(data) {
     return request.post(
-      `/service?ssServ=ss.login&wdConfirmationCaptchaService=0&mdToken=${data.mdToken}`,
+      `/service?ssServ=ssLogin&wdConfirmationCaptchaService=0&mdToken=${data.mdToken}`,
       data,
       {
         loading: false // 自动登录不显示 loading,避免打扰用户
@@ -68,7 +68,7 @@ export const userApi = {
   // 微信登录 - 自定义 loading
   wechatLogin(data, onComplete) {
     const promise = request.post(
-      `/service?ssServ=ss.login&wdConfirmationCaptchaService=0`,
+      `/service?ssServ=ssLogin&wdConfirmationCaptchaService=0`,
       data,
       {
         loading: false, // 禁用全局 loading,由登录页面自己控制

+ 2 - 2
config/env.js

@@ -1,5 +1,5 @@
 export default {
   // baseUrl: 'http://192.168.220.13:8080',
-  // baseUrl: 'https://m.hfdcschool.com',
-  baseUrl: 'https://yx.newfeifan.cn',
+  baseUrl: 'https://m.hfdcschool.com',
+  // baseUrl: 'https://yx.newfeifan.cn',
 }

+ 2 - 2
pages/common/webview.vue

@@ -31,8 +31,8 @@ const webviewUrl = ref('')
 const buildWebviewUrl = (params) => {
   // 基础 H5 域名 - 同域部署
   // const baseUrl = 'http://127.0.0.1:5501/page/'
-  const baseUrl = 'https://yx.newfeifan.cn/page/'
-  // const baseUrl = 'https://m.hfdcschool.com/page'
+  // const baseUrl = 'https://yx.newfeifan.cn/page/'
+  const baseUrl = 'https://m.hfdcschool.com/page'
   // const baseUrl = 'http://192.168.220.13:8080/page'
   
   

+ 76 - 37
pages/device/index.vue

@@ -70,7 +70,7 @@
 
 			<!-- 右侧消息面板 -->
 			<view class="right-panel">
-				<message-page v-if="currentContact" :contact-id="currentContact.id" role="device"></message-page>
+				<message-page v-if="currentContact" :contact-id="currentContact.id" :contact-name="currentContact.username" role="device"></message-page>
 				<!-- <view v-else class="empty-message">
 					<text>请选择联系人开始聊天</text>
 				</view> -->
@@ -113,8 +113,8 @@
 							</view>
 							<text>视频</text>
 						</view>
-						<view class="action-btn" :class="{ 'message-point': contact.hasPoints }"
-							@click="goToMessage(contact.id)">
+						<view class="action-btn" :class="{ 'message-point': contact.hasPoints }"
+							@click="goToMessage(contact)">
 							<view class="action-btn-icon">
 								<image src="/static/icon/message.png"></image>
 							</view>
@@ -167,6 +167,7 @@ import messagePage from '../parent/message.vue'
 import customModal from '@/components/custom-modal.vue'
 import { deviceApi } from '@/api/device'
 import { getImageUrl } from '@/utils/util'
+import websocketService from '@/utils/websocket'
 
 
 const wmpfVoip = requirePlugin('wmpf-voip').default
@@ -250,8 +251,9 @@ export default {
 				this.studentAvatar = getImageUrl(userInfo.yszwj)
 			}
 
-			// 恢复 sn
-			this.sn = userInfo.devId
+			// 恢复 sn
+			this.sn = userInfo.devId
+			await this.ensureDeviceSocketConnected()
 
 			// 获取联系人列表
 			await this.getContactList()
@@ -283,10 +285,11 @@ export default {
 		}
 
 		// 🆕 新登录流程:从 options 获取 sn 和 cardNo
-		if (options && options.sn) {
-			this.sn = options.sn
-		}
-		const cardNo = options && options.cardNo ? options.cardNo : ''
+		if (options && options.sn) {
+			this.sn = options.sn
+		}
+		await this.ensureDeviceSocketConnected()
+		const cardNo = options && options.cardNo ? options.cardNo : ''
 
 		// 可选的 studentId 参数
 		if (options && options.studentId) {
@@ -316,16 +319,33 @@ export default {
 			});
 		}
 	},
-		onUnload() {
+			onUnload() {
 			uni.$off('device-message-refresh', this.handleDeviceMessageRefresh)
 			// 清理定时器
 			if (this.inactivityTimer) {
 				clearTimeout(this.inactivityTimer);
 			}
 		},
-	methods: {
-		// 显示自定义Modal
-		showCustomModal(options = {}) {
+		async onShow() {
+			await this.ensureDeviceSocketConnected()
+		},
+	methods: {
+		async ensureDeviceSocketConnected() {
+			const ssDevId = String(this.sn || '').trim()
+			if (!ssDevId) return
+			try {
+				await websocketService.ensureConnected({
+					role: 'device',
+					ssDevId,
+					heartbeat: true,
+					autoReconnect: true
+				})
+			} catch (error) {
+				console.error('index 建立设备 websocket 失败:', error)
+			}
+		},
+		// 显示自定义Modal
+		showCustomModal(options = {}) {
 			return new Promise((resolve) => {
 				this.modalTitle = options.title || '提示'
 				this.modalContent = options.content || ''
@@ -452,13 +472,22 @@ export default {
 			}
 		},
 
-		// 通用通话方法,供音频和视频通话共用
-		async startCall(contact, roomType) {
-			// 显示loading弹窗
-			uni.showLoading({
-				title: '呼叫中...',
-				mask: true  // 添加遮罩,防止触摸穿透
-			});
+		// 通用通话方法,供音频和视频通话共用
+		async startCall(contact, roomType) {
+			const contactOpenid = (contact && (contact.openid || contact.wbid2 || contact.wbid || '') || '').trim()
+			if (!contactOpenid) {
+				uni.showToast({
+					title: '请让家长先关注公众号,登陆小程序后再发起',
+					icon: 'none'
+				})
+				return false
+			}
+
+			// 显示loading弹窗
+			uni.showLoading({
+				title: '呼叫中...',
+				mask: true  // 添加遮罩,防止触摸穿透
+			});
 
 			// 记录当前联系人
 			this.currentContact = contact;
@@ -480,13 +509,13 @@ export default {
 			try {
 				const res = await wmpfVoip.initByCaller({
 					roomType: roomType,
-					caller: {
-						id: this.sn,
-					},
-					listener: {
-						id: contact.openid,
-						name: contact.username
-					},
+					caller: {
+						id: this.sn,
+					},
+					listener: {
+						id: contactOpenid,
+						name: contact.username
+					},
 					businessType: 1,
 					miniprogramState: miniprogramState
 				})
@@ -607,9 +636,16 @@ export default {
 			// 杀后台方法 by xu 2025-12-29
 			async killBackgroundApp() {
 				try {
+					try {
+						await deviceApi.ssExit()
+						console.log('✅ 设备退出服务调用成功')
+					} catch (exitError) {
+						console.warn('⚠️ 设备退出服务调用失败:', exitError)
+					}
+
 					// 🧹 清理登录信息
 					uni.removeStorageSync('userInfo')
-				uni.removeStorageSync('JSESSIONID')
+				uni.removeStorageSync('JSESSIONID')
 
 				// 🧹 清理通话相关缓存
 				uni.removeStorageSync('currcall')
@@ -669,17 +705,18 @@ export default {
 				const result = await deviceApi.grfw_selChatMbr()
 				const data = result && result.data ? result.data : {}
 				const chatMbrList = Array.isArray(data.chatMbrList) ? data.chatMbrList : []
+				const flatChatMbrList = chatMbrList.flatMap((item) => (Array.isArray(item) ? item : [item])).filter(Boolean)
 
-				if (chatMbrList.length) {
+				if (flatChatMbrList.length) {
 					const defaultAvatar = 'https://thirdwx.qlogo.cn/mmopen/vi_32/POgEwh4mIHO4nibH0KlMECNjjGxQUq24ZEaGT4poC6icRiccVGKSyXwibcPq4BWmiaIGuG1icwxaQX6grC9VemZoJ8rg/132'
-					this.contacts = chatMbrList.map((item) => ({
+					this.contacts = flatChatMbrList.map((item) => ({
 						id: item.ryid,
 						username: item.xm || '未命名联系人',
 						avatar: item.yszwj ? getImageUrl(item.yszwj) : defaultAvatar,
-						openid: item.wbid || '',
+						openid: item.wbid2 || item.wbid || '',
 						hasPoints: false
 					}))
-					console.log('✅ 获取留言联系人成功:', chatMbrList)
+					console.log('✅ 获取留言联系人成功:', flatChatMbrList)
 					return
 				}
 
@@ -701,11 +738,13 @@ export default {
 				console.error('❌ 获取联系人失败:', error)
 			}
 		},
-		goToMessage(contactId) {
-			uni.navigateTo({
-				url: '/pages/parent/message?contactId=' + contactId + "&role=device"
-			})
-		},
+		goToMessage(contact) {
+			const contactId = contact && contact.id ? contact.id : ''
+			const contactName = contact && contact.username ? contact.username : ''
+			uni.navigateTo({
+				url: '/pages/parent/message?contactId=' + encodeURIComponent(contactId) + "&contactName=" + encodeURIComponent(contactName) + "&role=device&fromIndex=1"
+			})
+		},
 
 		// 检查个人服务状态
 		async checkServiceStatus(grfwxmm = 115) {

+ 24 - 11
pages/device/notice.vue

@@ -25,7 +25,7 @@
 						class="avatar student-avatar"
 						:src="item.avatar"
 						mode="aspectFill"
-						:style="{ width: item.avatarSize + 'px', height: item.avatarSize + 'px' }"
+						:style="{ width: item.avatarWidth + 'px', height: item.avatarHeight + 'px' }"
 					/>
 					<view class="name" :style="{ fontSize: item.fontSize + 'px' }">{{ item.name }}</view>
 				</view>
@@ -52,7 +52,7 @@
 						class="avatar parent-avatar"
 						:src="item.avatar"
 						mode="aspectFill"
-						:style="{ width: item.avatarSize + 'px', height: item.avatarSize + 'px' }"
+						:style="{ width: item.avatarWidth + 'px', height: item.avatarHeight + 'px' }"
 					/>
 					<view class="name" :style="{ fontSize: item.fontSize + 'px' }">{{ item.name }}</view>
 				</view>
@@ -85,6 +85,8 @@ const USE_MOCK_WHEN_EMPTY = true
 const studentNotifyUsers = ref([])
 const parentUnreadUsers = ref([])
 const deviceSn = ref('')
+const noticeBootstrapped = ref(false)
+const skipFirstOnShow = ref(false)
 const wsUnsubscribers = []
 
 const totalNotifyCount = computed(() => studentNotifyUsers.value.length + parentUnreadUsers.value.length)
@@ -203,6 +205,7 @@ function buildSectionLayout(users = [], sectionHeight = 0, options = {}) {
 	}
 
 	const avatarScale = options.avatarScale || 0.55
+	const avatarRatio = options.avatarRatio || 1 // width / height
 	const maxSingleCardWidth = options.maxSingleCardWidth || 600
 	const baseCols = options.baseCols || 4
 	const areaWidth = AVAILABLE_WIDTH
@@ -231,7 +234,8 @@ function buildSectionLayout(users = [], sectionHeight = 0, options = {}) {
 	const baseFontSize = 30 * (SCREEN_WIDTH / 750)
 	const fontSize = Math.max(12, (cardWidth / baseCardWidth) * baseFontSize)
 	const gap = Math.max(6, cardWidth * 0.06)
-	const avatarSize = cardWidth * avatarScale
+	const avatarWidth = cardWidth * avatarScale
+	const avatarHeight = avatarWidth / avatarRatio
 
 	const positions = list.map((item, index) => {
 		const row = Math.floor(index / cols)
@@ -248,7 +252,8 @@ function buildSectionLayout(users = [], sectionHeight = 0, options = {}) {
 			height: cardHeight,
 			fontSize,
 			gap,
-			avatarSize
+			avatarWidth,
+			avatarHeight
 		}
 	})
 
@@ -258,8 +263,7 @@ function buildSectionLayout(users = [], sectionHeight = 0, options = {}) {
 const sectionHeights = computed(() => {
 	const studentCount = studentNotifyUsers.value.length
 	const parentCount = parentUnreadUsers.value.length
-	const total = studentCount + parentCount
-	if (!total) {
+	if (!studentCount && !parentCount) {
 		return { student: 0, parent: 0 }
 	}
 	if (!studentCount) {
@@ -268,8 +272,7 @@ const sectionHeights = computed(() => {
 	if (!parentCount) {
 		return { student: AVAILABLE_HEIGHT, parent: 0 }
 	}
-
-	let studentHeight = Math.round((AVAILABLE_HEIGHT * studentCount) / total)
+	let studentHeight = Math.round(AVAILABLE_HEIGHT * 0.7)
 	studentHeight = Math.max(MIN_SECTION_HEIGHT, Math.min(studentHeight, AVAILABLE_HEIGHT - MIN_SECTION_HEIGHT))
 	return {
 		student: studentHeight,
@@ -279,11 +282,13 @@ const sectionHeights = computed(() => {
 
 const studentLayout = computed(() => buildSectionLayout(studentNotifyUsers.value, sectionHeights.value.student, {
 	avatarScale: 0.56,
+	avatarRatio: 68 / 100,
 	maxSingleCardWidth: 260
 }))
 
 const parentLayout = computed(() => buildSectionLayout(parentUnreadUsers.value, sectionHeights.value.parent, {
 	avatarScale: 0.48,
+	avatarRatio: 1,
 	maxSingleCardWidth: 220
 }))
 
@@ -349,9 +354,10 @@ async function ensureNoticeSocketConnected() {
 	try {
 		await websocketService.ensureConnected({
 			role: 'device',
-			ssDev: deviceSn.value,
+			ssDevId: deviceSn.value,
 			heartbeat: true,
-			autoReconnect: true
+			autoReconnect: true,
+			maxReconnectAttempts: 5
 		})
 	} catch (error) {
 		console.error('notice 建立设备 websocket 失败:', error)
@@ -360,11 +366,18 @@ async function ensureNoticeSocketConnected() {
 
 onLoad(async (options) => {
 	deviceSn.value = resolveDeviceSn(options || {})
+	noticeBootstrapped.value = true
+	skipFirstOnShow.value = true
 	await refreshNoticeUsers()
 	await ensureNoticeSocketConnected()
 })
 
 onShow(async () => {
+	if (!noticeBootstrapped.value) return
+	if (skipFirstOnShow.value) {
+		skipFirstOnShow.value = false
+		return
+	}
 	if (!deviceSn.value) {
 		deviceSn.value = resolveDeviceSn({})
 	}
@@ -445,7 +458,7 @@ onUnload(() => {
 }
 
 .student-avatar {
-	border-radius: 6rpx;
+	border-radius: 4rpx;
 	border: 1px solid #e6e6e6;
 }
 

+ 16 - 7
pages/main/index.vue

@@ -272,19 +272,28 @@ function buildPagesFromAuth() {
 // 主容器的生命周期
 onLoad((options) => {
     // 测试写死
-    // options.sn = 'A100006B6256E6'
-    // options.cardNo = 'E00401532101245F'
+    options.sn = 'ssDevId_a'
+    options.cardNo = 'E00401532101245F'
     if(options.cardNo == 'E004015316BE6182'){
         options.cardNo = 'E004015316BE61821'
     }
     // if (true) {
-
     if (typeof wmpf !== 'undefined') { 
         console.log('WMPF环境')
-
-        uni.reLaunch({
-            url: `/pages/device/index?sn=${options.sn}&cardNo=${options.cardNo}`
-        })
+        const sn = options.sn || ''
+        const cardNo = options.cardNo || ''
+        if (sn && cardNo) {
+            uni.reLaunch({
+                url: `/pages/parent/message?role=device&sn=${encodeURIComponent(sn)}&cardNo=${encodeURIComponent(cardNo)}`
+            })
+            return
+        }
+        if (sn && !cardNo) {
+            uni.reLaunch({
+                url: `/pages/device/notice?sn=${sn}`
+            })
+            return
+        }
     } else {
         console.log('非WMPF环境')
         //  console.log('主容器页面加载', options)

+ 575 - 40
pages/parent/message.vue

@@ -1,6 +1,6 @@
 <template>
 	<view class="message-page">
-		<view v-if="sessionRole === 'parent'" class="chat-nav">
+		<view class="chat-nav">
 			<view class="chat-title-wrap">
 				<picker
 					v-if="parentChatMembers.length > 1"
@@ -10,7 +10,7 @@
 					@change="handleParentMemberChange"
 				>
 					<view class="chat-title-picker">
-						<text class="chat-name-arrow">▼</text>
+						<Icon lib="base" name="icon-down" size="36" color="#000000" />
 						<text class="chat-title">{{ currentChatName }}</text>
 					</view>
 				</picker>
@@ -25,6 +25,9 @@
 				<view class="chat-call-btn" @click="startVideoCall">
 					<Icon lib="base" name="icon-vid-bold" size="36" color="#3c4151" />
 				</view>
+				<view v-if="sessionRole === 'device'" class="chat-call-btn" @click="handleDeviceLogout">
+					<image class="chat-logout-icon" src="/static/icon/logout.png"></image>
+				</view>
 			</view>
 		</view>
 
@@ -35,14 +38,22 @@
 				:id="'msg-' + index">
 				<!-- 头像+时间区域 -->
 				<view class="avatar-section">
-					<image class="avatar" src="/static/logo.png"></image>
-					<text class="msg-time">{{ message.time }}</text>
+					<image
+						class="avatar"
+						:class="getAvatarClass(message)"
+						:src="getAvatarSrc(message)"
+						mode="aspectFill"
+					></image>
+					<text class="msg-time">{{ message.time || '--:--' }}</text>
 				</view>
 
 				<!-- 内容区域 -->
 				<view class="content-section">
-					<!-- 部门/班级 | 姓名 -->
-					<view class="user-info">{{ message.department }} | {{ message.name }}</view>
+					<!-- 职务 | 姓名(职务先隐藏) -->
+					<view class="user-info" :class="{ 'user-info-placeholder': message.direction === 'right' }">
+						<text class="user-info-role">{{ message.department }} | </text>
+						<text>{{ message.direction === 'left' ? (message.displayName || message.name) : '占位' }}</text>
+					</view>
 
 					<!-- 消息内容 -->
 					<view class="message-content">
@@ -149,7 +160,7 @@
 						@touchend.stop.prevent="handlePressToTalkEnd"
 						@touchcancel.stop.prevent="handlePressToTalkCancel"
 					>
-						<Icon lib="base" name="icon-aud-bold" size="36" :color="iconColor" />
+						<Icon lib="base" name="icon-aud" size="36" :color="iconColor" />
 						<text class="press-text">长按说话</text>
 					</view>
 					<textarea
@@ -194,7 +205,7 @@
 					</view>
 					<view class="more-item" @click="handleMoreAction('camera')">
 						<view class="more-icon">
-							<Icon lib="base" name="icon-vid-bold" size="36" :color="iconColor" />
+							<Icon lib="base" name="icon-photo" size="36" :color="iconColor" />
 						</view>
 						<text class="more-text">拍照</text>
 					</view>
@@ -265,11 +276,22 @@
 					</view>
 				</view>
 			</view>
+
+			<custom-modal
+				:visible="modalVisible"
+				:title="modalTitle"
+				:content="modalContent"
+				:showCancel="modalShowCancel"
+				:confirmText="modalConfirmText"
+				@confirm="handleModalConfirm"
+				@cancel="handleModalCancel"
+			/>
 		</view>
 	</template>
 
 <script>
 import Icon from '@/components/icon/index.vue'
+import customModal from '@/components/custom-modal.vue'
 import { EMOJI_LIST } from '@/constants/emoji'
 import { collectImageUrls, createImageMessage, createTextMessage } from '@/utils/parent-message-factory'
 import {
@@ -284,6 +306,7 @@ import websocketService from '@/utils/websocket'
 import upload from '@/utils/upload'
 import { getImageUrl } from '@/utils/util'
 import { grfwApi } from '@/api/grfw'
+import { deviceApi } from '@/api/device'
 import env from '@/config/env.js'
 
 const RECEIPT_TOGGLE_TTL_MS = 60 * 1000
@@ -323,13 +346,18 @@ const miniprogramState = (() => {
 				type: [String, Number],
 				default: ''
 			},
+			contactName: {
+				type: String,
+				default: ''
+			},
 			studentId: {
 				type: [String, Number],
 				default: ''
 			}
 		},
 			components: {
-				Icon
+				Icon,
+				customModal
 			},
 			computed: {},
 			watch: {
@@ -341,6 +369,9 @@ const miniprogramState = (() => {
 					this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
 					this.bootstrapMessageFlow()
 				},
+				contactName() {
+					this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, contactName: this.contactName, studentId: this.studentId })
+				},
 				studentId() {
 					this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
 					this.bootstrapMessageFlow()
@@ -384,7 +415,7 @@ const miniprogramState = (() => {
 				currentUserMeta: {
 					direction: 'right',
 					department: '家长',
-				name: ''
+				name: '家长'
 			},
 			textLineCount: 1,
 			listTouchMoved: false,
@@ -403,6 +434,7 @@ const miniprogramState = (() => {
 				sessionRole: 'parent',
 				sessionContactId: '',
 				sessionStudentId: '',
+				entryFromDeviceIndex: false,
 				socketConnected: false,
 				socketUnsubscribeList: [],
 				currentWsConfig: null,
@@ -411,6 +443,19 @@ const miniprogramState = (() => {
 					recRyid: '',
 					recRylbm: REC_RYLBM_STUDENT
 				},
+				deviceSessionReady: true,
+				deviceInitPromise: null,
+				peerAvatarTypeHint: '',
+				serviceStatus: null,
+				inactivityTimer: null,
+				inactivityTimeout: 180000,
+				modalVisible: false,
+				modalTitle: '提示',
+				modalContent: '',
+				modalShowCancel: false,
+				modalConfirmText: '确定',
+				modalResolve: null,
+				loadOptions: {},
 				// 消息列表数据
 				messages: []
 				}
@@ -419,14 +464,21 @@ const miniprogramState = (() => {
 		this.initializeSessionFromOptions({
 			role: this.role,
 			contactId: this.contactId,
+			contactName: this.contactName,
 			studentId: this.studentId
 		})
 	},
-	onLoad(options) {
+	async onLoad(options) {
+		this.loadOptions = { ...(options || {}) }
 		this.initializeSessionFromOptions({ ...(options || {}), force: true })
 		this.initParentInfo()
+		if (this.sessionRole === 'device') {
+			this.deviceSessionReady = false
+			this.deviceInitPromise = this.initDeviceSession(options || {})
+			this.deviceSessionReady = await this.deviceInitPromise
+		}
 	},
-			onReady() {
+			async onReady() {
 				// 页面渲染完成后,滚动到最新消息
 				this.$nextTick(() => {
 					this.scrollToBottom()
@@ -435,7 +487,14 @@ const miniprogramState = (() => {
 				})
 				this.registerVoipEvent()
 				this.bindSocketListeners()
-				this.bootstrapMessageFlow()
+				if (this.sessionRole === 'device') {
+					await (this.deviceInitPromise || Promise.resolve(true))
+					if (!this.deviceSessionReady) return
+					await this.checkServiceStatus(115)
+					await this.checkServiceStatus(111)
+					this.resetInactivityTimer()
+				}
+				await this.bootstrapMessageFlow()
 			},
 			onUnload() {
 				this.teardownSocket()
@@ -446,22 +505,62 @@ const miniprogramState = (() => {
 				}
 				this.cleanupRecorder()
 				this.clearReceiptToggleTimers()
+				if (this.inactivityTimer) {
+					clearTimeout(this.inactivityTimer)
+					this.inactivityTimer = null
+				}
 			},
 			beforeUnmount() {
 				this.teardownSocket()
 			},
 			methods: {
+				showCustomModal(options = {}) {
+					return new Promise((resolve) => {
+						this.modalTitle = options.title || '提示'
+						this.modalContent = options.content || ''
+						this.modalShowCancel = options.showCancel || false
+						this.modalConfirmText = options.confirmText || '确定'
+						this.modalResolve = resolve
+						this.modalVisible = true
+					})
+				},
+				handleModalConfirm() {
+					this.modalVisible = false
+					if (this.modalResolve) {
+						this.modalResolve(true)
+						this.modalResolve = null
+					}
+				},
+				handleModalCancel() {
+					this.modalVisible = false
+					if (this.modalResolve) {
+						this.modalResolve(false)
+						this.modalResolve = null
+					}
+				},
 				initializeSessionFromOptions(options = {}) {
 					if (this.sessionInitialized && !options?.force) return
+					const safeDecode = (value) => {
+						if (value === null || value === undefined) return ''
+						const text = String(value)
+						try {
+							return decodeURIComponent(text)
+						} catch (error) {
+							return text
+						}
+					}
 					const role = options.role || this.role || 'parent'
 					this.sessionRole = role
-					this.sessionContactId = options.contactId || this.contactId || ''
+					this.sessionContactId = safeDecode(options.contactId || this.contactId || '')
 					this.sessionStudentId = options.studentId || this.studentId || ''
-					this.currentChatName = role === 'device' ? '家长' : '留言'
+					this.entryFromDeviceIndex = String(options.fromIndex || '') === '1'
+					const targetContactName = safeDecode(options.contactName || this.contactName || '')
+					this.currentChatName = role === 'device' ? (targetContactName || '家长') : '留言'
 					this.currentUserMeta = {
 						...this.currentUserMeta,
 						department: role === 'device' ? '设备端' : '家长'
 					}
+					this.refreshCurrentUserMeta()
 					this.resolveWsIdentity()
 					this.currentWsConfig = this.buildWsConnectOptions()
 					this.sessionInitialized = true
@@ -473,9 +572,65 @@ const miniprogramState = (() => {
 					if (this.sessionRole === 'parent') {
 						await this.loadParentChatMembers()
 					} else {
+						await this.loadDeviceChatMembers()
 						await this.ensureSessionSocket()
 					}
 					await this.loadHistoryMessages({ reset: true })
+					if (this.sessionRole === 'device' && this.entryFromDeviceIndex) {
+						await this.loadHistoryMessages({ reset: true })
+					}
+				},
+				async initDeviceSession(options = {}) {
+					if (this.sessionRole !== 'device') return
+					const userInfo = this.getStoredUserInfo()
+					const snFromOption = String(options.sn || '').trim()
+					const cardNo = String(options.cardNo || '').trim()
+					const sn = snFromOption || String(userInfo.devId || '').trim()
+					if (!sn || !cardNo) return false
+					try {
+						const result = await deviceApi.login(sn, cardNo)
+						const loginData = result?.data || {}
+						if (!loginData || loginData.msg) {
+							await this.handleDeviceLoginFailure('登录失败,卡无登记')
+							return false
+						}
+						const mergedUserInfo = {
+							...this.getStoredUserInfo(),
+							devId: loginData.devId || sn,
+							sbmc: loginData.sbmc || '',
+							sessId: loginData.sessId || '',
+							xm: loginData.xm || '',
+							yhsbToken: loginData.yhsbToken || '',
+							onlineToken: loginData.onlineToken || '',
+							syList: loginData.sylist || loginData.syList || [],
+							yhid: loginData.yhid || '',
+							yhm: loginData.yhm || ''
+						}
+						if (loginData.yszwj) mergedUserInfo.yszwj = loginData.yszwj
+						if (loginData.zjzwj) mergedUserInfo.zjzwj = loginData.zjzwj
+						uni.setStorageSync('userInfo', mergedUserInfo)
+						if (mergedUserInfo.sessId) {
+							uni.setStorageSync('JSESSIONID', mergedUserInfo.sessId)
+						}
+						this.refreshCurrentUserMeta()
+						this.resolveWsIdentity()
+						this.currentWsConfig = this.buildWsConnectOptions()
+						return true
+					} catch (error) {
+						console.error('设备端登录失败:', error)
+						await this.handleDeviceLoginFailure('网络错误或服务异常')
+						return false
+					}
+				},
+				async handleDeviceLoginFailure(content = '登录失败') {
+					await this.showCustomModal({
+						title: '登录失败',
+						content,
+						showCancel: false,
+						confirmText: '确定'
+					})
+					await this.handleDeviceLogout()
+					return false
 				},
 				async loadParentChatMembers() {
 					try {
@@ -503,6 +658,31 @@ const miniprogramState = (() => {
 						uni.showToast({ title: '加载孩子列表失败', icon: 'none' })
 					}
 				},
+				async loadDeviceChatMembers() {
+					try {
+						const result = await deviceApi.grfw_selChatMbr()
+						const data = result?.data || {}
+						const list = this.normalizeChatMemberList(data.chatMbrList)
+						this.parentChatMembers = list
+						this.parentChatMemberNames = list.map((item) => item.xm || String(item.ryid || '未命名家长'))
+						if (!list.length) {
+							this.sessionContactId = ''
+							this.currentChatName = '家长'
+							this.resolveWsIdentity()
+							return
+						}
+						let targetIndex = 0
+						const current = String(this.sessionContactId || '')
+						if (current) {
+							const idx = list.findIndex((item) => String(item.ryid || '') === current)
+							if (idx > -1) targetIndex = idx
+						}
+						this.selectParentMemberByIndex(targetIndex)
+					} catch (error) {
+						console.error('加载家长列表失败:', error)
+						uni.showToast({ title: '加载家长列表失败', icon: 'none' })
+					}
+				},
 				normalizeChatMemberList(rawList) {
 					if (!Array.isArray(rawList)) return []
 					const queue = [...rawList]
@@ -519,10 +699,14 @@ const miniprogramState = (() => {
 					}
 					return result
 				},
-				handleParentMemberChange(event) {
+				async handleParentMemberChange(event) {
+					if (this.historyLoading) {
+						await this.finishHistoryLoading()
+					}
 					const index = Number(event?.detail?.value || 0)
+					console.log('handleParentMemberChange -> index', index)
 					this.selectParentMemberByIndex(index)
-					this.loadHistoryMessages({ reset: true })
+					await this.loadHistoryMessages({ reset: true })
 				},
 				selectParentMemberByIndex(index = 0) {
 					const safeIndex = Math.max(0, Math.min(index, this.parentChatMembers.length - 1))
@@ -530,6 +714,7 @@ const miniprogramState = (() => {
 					const target = this.parentChatMembers[safeIndex] || {}
 					this.sessionContactId = String(target.ryid || '')
 					this.currentChatName = target.xm || '留言'
+					this.refreshCurrentUserMeta()
 					this.resolveWsIdentity()
 					this.currentWsConfig = this.buildWsConnectOptions()
 				},
@@ -547,9 +732,132 @@ const miniprogramState = (() => {
 						...this.parentInfo,
 						...info
 					}
+					this.refreshCurrentUserMeta()
+				},
+				getStoredUserInfo() {
+					let info = uni.getStorageSync('userInfo') || {}
+					if (typeof info === 'string') {
+						try {
+							info = JSON.parse(info)
+						} catch (error) {
+							info = {}
+						}
+					}
+					return info && typeof info === 'object' ? info : {}
+				},
+				getSelfDisplayMeta() {
+					const userInfo = this.getStoredUserInfo()
+					if (this.sessionRole === 'device') {
+						return {
+							department: '学生',
+							name: userInfo.xm || this.currentUserMeta.name || '学生'
+						}
+					}
+					return {
+						department: '家长',
+						name: this.parentInfo?.xm || userInfo.xm || this.currentUserMeta.name || '家长'
+					}
+				},
+				getPeerDisplayMeta() {
+					if (this.sessionRole === 'device') {
+						return {
+							department: '家长',
+							name: this.currentChatName || '家长'
+						}
+					}
+					return {
+						department: '学生',
+						name: this.currentChatName || '学生'
+					}
+				},
+				resolveMessageMetaByDirection(direction = 'right') {
+					return direction === 'right' ? this.getSelfDisplayMeta() : this.getPeerDisplayMeta()
+				},
+				getSelfAvatarMeta() {
+					const userInfo = this.getStoredUserInfo()
+					const peerType = String(this.peerAvatarTypeHint || '')
+					const preferType = peerType === '1' ? '51' : (peerType === '51' ? '1' : '')
+					if (preferType === '51') {
+						return {
+							url: this.toDisplayImageUrl(userInfo.zjzwj || userInfo.yszwj || ''),
+							type: '51'
+						}
+					}
+					if (preferType === '1') {
+						return {
+							url: this.toDisplayImageUrl(userInfo.yszwj || userInfo.zjzwj || ''),
+							type: '1'
+						}
+					}
+					if (this.sessionRole === 'device') {
+						if (userInfo.zjzwj) {
+							return {
+								url: this.toDisplayImageUrl(userInfo.zjzwj),
+								type: '51'
+							}
+						}
+						if (userInfo.yszwj) {
+							return {
+								url: this.toDisplayImageUrl(userInfo.yszwj),
+								type: '1'
+							}
+						}
+						return {
+							url: '/static/logo.png',
+							type: ''
+						}
+					}
+					if (userInfo.yszwj) {
+						return {
+							url: this.toDisplayImageUrl(userInfo.yszwj),
+							type: '1'
+						}
+					}
+					if (userInfo.zjzwj) {
+						return {
+							url: this.toDisplayImageUrl(userInfo.zjzwj),
+							type: '51'
+						}
+					}
+					return {
+						url: '/static/logo.png',
+						type: ''
+					}
+				},
+				getAvatarSrc(message = {}) {
+					if (!message || message.direction === 'right') {
+						return this.getSelfAvatarMeta().url
+					}
+					return message.avatarUrl || '/static/logo.png'
+				},
+				getAvatarClass(message = {}) {
+					const type = String(
+						message && message.direction === 'right'
+							? this.getSelfAvatarMeta().type
+							: (message.avatarType || '')
+					)
+					return {
+						'square-avatar': type === '51',
+						'doc-avatar': type === '51'
+					}
+				},
+				refreshCurrentUserMeta() {
+					const selfMeta = this.getSelfDisplayMeta()
+					this.currentUserMeta = {
+						...this.currentUserMeta,
+						department: selfMeta.department,
+						name: selfMeta.name
+					}
 				},
 				buildCallContactByCurrentMember() {
 					const member = this.parentChatMembers[this.parentChatMemberIndex] || {}
+					if (this.sessionRole === 'device') {
+						return {
+							username: member.xm || member.username || '家长',
+							avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
+							openid: member.wbid2 || member.wbid || ''
+						}
+					}
 					return {
 						username: member.xm || member.username || '学生',
 						avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
@@ -570,6 +878,57 @@ const miniprogramState = (() => {
 						uni.showToast({ title: '通话能力暂不可用', icon: 'none' })
 						return
 					}
+					if (this.sessionRole === 'device') {
+						const userInfo = this.getStoredUserInfo()
+						const callerId = String(userInfo.devId || this.currentWsConfig?.ssDevId || '').trim()
+						const listenerId = String(contact?.openid || '').trim()
+						if (!callerId) {
+							uni.showToast({ title: '缺少设备标识', icon: 'none' })
+							return
+						}
+						if (!listenerId) {
+							uni.showToast({ title: '请让家长先关注公众号,登陆小程序后再发起', icon: 'none' })
+							return
+						}
+						uni.showLoading({ title: '呼叫中...', mask: true })
+						uni.setStorageSync(CALL_END_STORAGE_KEY, {
+							name: contact?.username || '家长',
+							avatar: contact?.avatar || '/static/logo.png',
+							duration: 0,
+							time: new Date().toLocaleString(),
+							type: roomType,
+							status: '呼叫中'
+						})
+						this.setVoipEndPagePath()
+						try {
+							const res = await wmpfVoip.initByCaller({
+								roomType,
+								caller: {
+									id: callerId,
+									name: userInfo.xm || '学生'
+								},
+								listener: {
+									id: listenerId,
+									name: contact?.username || '家长'
+								},
+								businessType: 1,
+								miniprogramState
+							})
+							if (res.isSuccess) {
+								uni.hideLoading()
+								uni.redirectTo({ url: wmpfVoip.CALL_PAGE_PATH })
+								return
+							}
+							uni.hideLoading()
+							uni.showToast({ title: '呼叫失败', icon: 'error' })
+							return
+						} catch (error) {
+							uni.hideLoading()
+							console.error('设备端通话异常:', error)
+							uni.showToast({ title: '发起通话失败', icon: 'none' })
+							return
+						}
+					}
 					const callerId = this.parentInfo?.openid || this.parentInfo?.wbid || ''
 					if (!callerId) {
 						uni.showToast({ title: '缺少家长身份标识', icon: 'none' })
@@ -624,14 +983,91 @@ const miniprogramState = (() => {
 						uni.showToast({ title: '发起通话失败', icon: 'none' })
 					}
 				},
+				async handleDeviceLogout() {
+					try {
+						await deviceApi.ssExit()
+					} catch (error) {
+						console.warn('设备端退出服务调用失败:', error)
+					}
+					uni.removeStorageSync('userInfo')
+					uni.removeStorageSync('JSESSIONID')
+					uni.removeStorageSync('currcall')
+					uni.removeStorageSync('lastCallInfo')
+					if (this.inactivityTimer) {
+						clearTimeout(this.inactivityTimer)
+						this.inactivityTimer = null
+					}
+					uni.showToast({
+						title: '已退出',
+						icon: 'success',
+						duration: 1200
+					})
+					setTimeout(() => {
+						uni.reLaunch({ url: '/pages/device/notice' })
+					}, 1200)
+				},
+				resetInactivityTimer() {
+					if (this.sessionRole !== 'device') return
+					if (this.inactivityTimer) {
+						clearTimeout(this.inactivityTimer)
+					}
+					this.inactivityTimer = setTimeout(() => {
+						this.handleDeviceLogout()
+					}, this.inactivityTimeout)
+				},
+				async checkServiceStatus(grfwxmm) {
+					if (this.sessionRole !== 'device') return
+					const code = Number(grfwxmm)
+					if (code !== 111 && code !== 115) {
+						console.warn('checkServiceStatus skip: invalid grfwxmm', grfwxmm)
+						return
+					}
+					try {
+						const result = await deviceApi.grfw_chkGrfw(code)
+						const data = result?.data || {}
+						if (data.ssCode === 0 && data.ssData) {
+							this.serviceStatus = data.ssData
+						}
+					} catch (error) {
+						console.error('检查服务状态失败:', error)
+					}
+				},
+				async recordCallAndRefresh(callOptions = {}) {
+					if (this.sessionRole !== 'device') return
+					try {
+						const duration = parseInt(callOptions.duration, 10) || 0
+						const callType = callOptions.callType || 'voice'
+						if (duration <= 0) return
+						const minutes = Math.ceil(duration / 60)
+						const grfwxmm = callType === 'video' ? 115 : 111
+						await deviceApi.grfw_endGrfw({
+							grfwxmm,
+							sc: minutes,
+							ll: 0,
+							ms: `通话${duration}秒`
+						})
+						await this.checkServiceStatus(grfwxmm)
+					} catch (error) {
+						console.error('记录通话失败:', error)
+					}
+				},
 				setVoipEndPagePath(forceUpdate = false) {
 					if (this._voipEndPathSet && !forceUpdate) return
 					if (!wmpfVoip) return
 					const callInfo = uni.getStorageSync(CALL_END_STORAGE_KEY) || {}
-					const query = [`role=parent`]
+					const query = [this.sessionRole === 'device' ? 'role=device' : 'role=parent']
 					if (this.sessionContactId) {
 						query.push(`contactId=${encodeURIComponent(this.sessionContactId)}`)
 					}
+					if (this.currentChatName) {
+						query.push(`contactName=${encodeURIComponent(this.currentChatName)}`)
+					}
+					if (this.sessionRole === 'device') {
+						const userInfo = this.getStoredUserInfo()
+						if (userInfo.devId) {
+							query.push(`sn=${encodeURIComponent(userInfo.devId)}`)
+						}
+					}
 					if (callInfo.name) {
 						query.push(`name=${encodeURIComponent(callInfo.name)}`)
 						query.push(`avatar=${encodeURIComponent(callInfo.avatar || '')}`)
@@ -659,6 +1095,9 @@ const miniprogramState = (() => {
 						if (hangupEvent.includes(eventName)) {
 							callInfo.duration = event.data?.keepTime || 0
 							callInfo.status = event.data?.keepTime > 0 ? '通话已结束' : '未接通'
+							if (callInfo.duration > 0) {
+								callInfo.needRecord = true
+							}
 						} else if (cancelEvent.includes(eventName)) {
 							callInfo.duration = 0
 							callInfo.status = '已取消'
@@ -675,13 +1114,44 @@ const miniprogramState = (() => {
 							uni.hideLoading()
 							uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
 							this.setVoipEndPagePath(true)
+							if (this.sessionRole === 'device' && callInfo.needRecord && callInfo.duration > 0) {
+								this.recordCallAndRefresh({
+									duration: callInfo.duration,
+									callType: callInfo.type || 'voice'
+								})
+								delete callInfo.needRecord
+								uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
+							}
+							this.resetInactivityTimer()
 						}
 					})
 					this._voipEventRegistered = true
 				},
 				formatPayloadTime(rawValue) {
 					if (!rawValue) return ''
-					const parsed = new Date(rawValue)
+					const normalized = String(rawValue)
+						.replace(/\u202f/g, ' ')
+						.replace(/\u00a0/g, ' ')
+						.replace(/\s+/g, ' ')
+						.trim()
+					let parsed = new Date(normalized)
+					if (Number.isNaN(parsed.getTime())) {
+						const monthMap = {
+							Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5,
+							Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11
+						}
+						const match = normalized.match(/^([A-Za-z]{3})\s+(\d{1,2}),\s*(\d{4}),\s*(\d{1,2}):(\d{2}):(\d{2})\s*(AM|PM)$/i)
+						if (match) {
+							const mon = monthMap[match[1]]
+							let hh = Number(match[4])
+							const mm = Number(match[5])
+							const ss = Number(match[6])
+							const ap = String(match[7]).toUpperCase()
+							if (ap === 'PM' && hh < 12) hh += 12
+							if (ap === 'AM' && hh === 12) hh = 0
+							parsed = new Date(Number(match[3]), mon, Number(match[2]), hh, mm, ss)
+						}
+					}
 					if (Number.isNaN(parsed.getTime())) return ''
 					const hh = String(parsed.getHours()).padStart(2, '0')
 					const mm = String(parsed.getMinutes()).padStart(2, '0')
@@ -732,6 +1202,7 @@ const miniprogramState = (() => {
 					if (reset) {
 						this.messages = []
 						this.scrollIntoView = ''
+						this.peerAvatarTypeHint = ''
 					}
 
 					const config = this.currentWsConfig || this.buildWsConnectOptions()
@@ -792,7 +1263,7 @@ const miniprogramState = (() => {
 						role: this.sessionRole
 					}
 					if (this.sessionRole === 'device') {
-						config.ssDev = String(userInfo.devId || '')
+						config.ssDevId = String(userInfo.devId || '')
 						config.heartbeat = true
 						config.autoReconnect = true
 					} else {
@@ -849,9 +1320,14 @@ const miniprogramState = (() => {
 					if (!this.currentWsConfig) {
 						this.initializeSessionFromOptions({ force: true })
 					}
-					const config = this.currentWsConfig || {}
+					let config = this.currentWsConfig || {}
 					if (config.role === 'parent') return
-					if (!config.ssDev) return
+					if (!config.ssDevId) {
+						this.resolveWsIdentity()
+						this.currentWsConfig = this.buildWsConnectOptions()
+						config = this.currentWsConfig || {}
+					}
+					if (!config.ssDevId) return
 					try {
 						await websocketService.ensureConnected(config)
 					} catch (error) {
@@ -897,12 +1373,20 @@ const miniprogramState = (() => {
 					const direction = String(payload.sendRyid || '') === String(this.wsIdentity.sendRyid || '')
 						? 'right'
 						: 'left'
+					const messageMeta = this.resolveMessageMetaByDirection(direction)
+					const payloadAlias = String(payload.alias || '').trim()
+					const payloadLogo = payload.logo ? this.toDisplayImageUrl(payload.logo) : ''
+					const payloadLogoType = String(payload.logoType || '')
+					if (direction === 'left' && (payloadLogoType === '1' || payloadLogoType === '51')) {
+						this.peerAvatarTypeHint = payloadLogoType
+					}
 					const msgId = payload.xxid || payload.msgId || ''
 					if (fromHistory && msgId) {
 						const exists = this.messages.some((item) => String(item.msgId || '') === String(msgId))
 						if (exists) return
 					}
 					const payloadTime = this.formatPayloadTime(payload.sendTime || payload.sendTimeStr || payload.time)
+					const displayTime = payloadTime || this.formatPayloadTime(new Date())
 					const receiptStatus = (payload.readTime || payload.rdTime || payload.readTm) ? 'read' : 'unread'
 
 					if (typeCode === '121') {
@@ -911,11 +1395,14 @@ const miniprogramState = (() => {
 							this.appendMessage({
 								type: 'file',
 								direction,
-								department: direction === 'right' ? this.currentUserMeta.department : '对方',
-								name: direction === 'right' ? this.currentUserMeta.name : '对方',
+								department: messageMeta.department,
+								name: messageMeta.name,
+								displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
+								avatarUrl: direction === 'left' ? payloadLogo : '',
+								avatarType: direction === 'left' ? payloadLogoType : '',
 								fileName: cont.baseName || this.getBaseNameFromPath(cont.fileName) || '未命名文件',
 								fileUrl: this.toDisplayFileUrl(cont.fileName || ''),
-								time: payloadTime || undefined,
+								time: displayTime,
 								needReceipt: true,
 								receiptStatus,
 								msgId
@@ -924,12 +1411,15 @@ const miniprogramState = (() => {
 						}
 						this.appendMessage(createTextMessage({
 							direction,
-							department: direction === 'right' ? this.currentUserMeta.department : '对方',
-							name: direction === 'right' ? this.currentUserMeta.name : '对方',
+							department: messageMeta.department,
+							name: messageMeta.name,
+							displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
+							avatarUrl: direction === 'left' ? payloadLogo : '',
+							avatarType: direction === 'left' ? payloadLogoType : '',
 							content: cont.body || '',
 							needReceipt: true,
 							receiptStatus,
-							time: payloadTime || undefined,
+							time: displayTime,
 							msgId
 						}))
 						return
@@ -938,10 +1428,13 @@ const miniprogramState = (() => {
 					if (typeCode === '122') {
 						this.appendMessage(createImageMessage({
 							direction,
-							department: direction === 'right' ? this.currentUserMeta.department : '对方',
-							name: direction === 'right' ? this.currentUserMeta.name : '对方',
+							department: messageMeta.department,
+							name: messageMeta.name,
+							displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
+							avatarUrl: direction === 'left' ? payloadLogo : '',
+							avatarType: direction === 'left' ? payloadLogoType : '',
 							imageUrl: this.toDisplayImageUrl(cont.fileName || cont.body || ''),
-							time: payloadTime || undefined,
+							time: displayTime,
 							needReceipt: true,
 							receiptStatus,
 							msgId
@@ -953,15 +1446,18 @@ const miniprogramState = (() => {
 						this.appendMessage({
 							type: 'voice',
 							direction,
-							department: direction === 'right' ? this.currentUserMeta.department : '对方',
-							name: direction === 'right' ? this.currentUserMeta.name : '对方',
+							department: messageMeta.department,
+							name: messageMeta.name,
+							displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
+							avatarUrl: direction === 'left' ? payloadLogo : '',
+							avatarType: direction === 'left' ? payloadLogoType : '',
 							duration: String(cont.duration || ''),
 							audioUrl: this.toDisplayFileUrl(cont.fileName || '', 'aud'),
 							voicePreview: '',
 							voiceText: cont.body || '',
 							needReceipt: true,
 							receiptStatus,
-							time: payloadTime || undefined,
+							time: displayTime,
 							msgId
 						})
 						return
@@ -971,12 +1467,15 @@ const miniprogramState = (() => {
 						this.appendMessage({
 							type: 'video',
 							direction,
-							department: direction === 'right' ? this.currentUserMeta.department : '对方',
-							name: direction === 'right' ? this.currentUserMeta.name : '对方',
+							department: messageMeta.department,
+							name: messageMeta.name,
+							displayName: direction === 'left' ? (payloadAlias || messageMeta.name) : messageMeta.name,
+							avatarUrl: direction === 'left' ? payloadLogo : '',
+							avatarType: direction === 'left' ? payloadLogoType : '',
 							coverUrl: '/static/logo.png',
 							videoUrl: this.toDisplayFileUrl(cont.fileName || '', 'vid'),
 							duration: String(cont.duration || ''),
-							time: payloadTime || undefined,
+							time: displayTime,
 							needReceipt: true,
 							receiptStatus,
 							msgId
@@ -1026,14 +1525,15 @@ const miniprogramState = (() => {
 					this.$set(this.messages[index], 'receiptStatus', 'read')
 				},
 				async sendWsPayload(payload) {
+					this.resetInactivityTimer()
 					const config = this.currentWsConfig || this.buildWsConnectOptions()
 					if (!config) return false
 					if (config.role === 'parent' && !config.ssToken) {
 						uni.showToast({ title: '缺少 ssToken', icon: 'none' })
 						return false
 					}
-					if (config.role === 'device' && !config.ssDev) {
-						uni.showToast({ title: '缺少 ssDev', icon: 'none' })
+					if (config.role === 'device' && !config.ssDevId) {
+						uni.showToast({ title: '缺少 ssDevId', icon: 'none' })
 						return false
 					}
 
@@ -1426,6 +1926,7 @@ const miniprogramState = (() => {
 			this.draftText = `${this.draftText}${emoji}`
 		},
 		handleListTouchStart(event) {
+			this.resetInactivityTimer()
 			const touch = event.touches && event.touches[0]
 			if (!touch) return
 			this.listTouchMoved = false
@@ -1433,6 +1934,7 @@ const miniprogramState = (() => {
 			this.touchStartY = touch.clientY
 		},
 		handleListTouchMove(event) {
+			this.resetInactivityTimer()
 			const touch = event.touches && event.touches[0]
 			if (!touch) return
 			const deltaX = Math.abs(touch.clientX - this.touchStartX)
@@ -1442,6 +1944,7 @@ const miniprogramState = (() => {
 			}
 		},
 		handleListTouchEnd() {
+			this.resetInactivityTimer()
 			if (!this.listTouchMoved) {
 				this.handlePageClick()
 			}
@@ -1723,6 +2226,12 @@ const miniprogramState = (() => {
 			justify-content: center;
 		}
 
+		.chat-logout-icon {
+			width: 36rpx;
+			height: 36rpx;
+			display: block;
+		}
+
 	.message-list {
 		flex: 1;
 		padding: 20rpx;
@@ -1752,6 +2261,14 @@ const miniprogramState = (() => {
 		flex-shrink: 0;
 	}
 
+	.avatar.square-avatar {
+		border-radius: 8rpx;
+	}
+
+	.avatar.doc-avatar {
+		object-position: center 5px;
+	}
+
 	.msg-time {
 		font-size: 32rpx;
 		color: #666;
@@ -1771,9 +2288,14 @@ const miniprogramState = (() => {
 		color: #666;
 	}
 
+	.user-info-role {
+		display: none;
+	}
+
 	.message-content {
 		display: flex;
-		align-items: center;
+		align-items: flex-end;
+		gap: 10rpx;
 	}
 
 	/* 文字消息容器 */
@@ -1938,6 +2460,10 @@ const miniprogramState = (() => {
 		border-radius: 50%;
 	}
 
+	.right .avatar.square-avatar {
+		border-radius: 8rpx;
+	}
+
 	.right .content-section {
 		align-items: flex-end;
 	}
@@ -1946,6 +2472,15 @@ const miniprogramState = (() => {
 		text-align: right;
 	}
 
+	.user-info-placeholder {
+		color: transparent;
+	}
+
+	.user-info-placeholder .user-info-role {
+		display: inline;
+	}
+
+
 	/* 右侧消息气泡样式 */
 		.right .text-message,
 		.right .call-message,

+ 73 - 6
utils/websocket.js

@@ -17,6 +17,15 @@ function getHostFromBaseUrl(baseUrl) {
     .replace(/\/+$/, '')
 }
 
+function getShortStack() {
+  try {
+    const stack = new Error().stack || ''
+    return stack.split('\n').slice(2, 8).join('\n')
+  } catch (error) {
+    return ''
+  }
+}
+
 class WebSocketService {
   constructor() {
     this.socketTask = null
@@ -69,15 +78,15 @@ class WebSocketService {
 
   buildSocketUrl(options = {}) {
     const role = options.role || 'parent'
-    const base = 'wss://yx.newfeifan.cn/btcSkt'
+    // const base = 'wss://yx.newfeifan.cn/btcSkt'
     // 开发地址(保留注释):const base = 'ws://192.168.220.13:8080/btcSkt'
-    // 旧线上地址(保留注释):const base = 'wss://m.hfdcschool.com/btcSkt'
+    const base = 'wss://m.hfdcschool.com/btcSkt'
     // 原 env 写法(保留注释):const base = `wss://${getHostFromBaseUrl(env.baseUrl)}/btcSkt`
 
     if (role === 'device') {
-      const ssDev = options.ssDev || ''
-      if (!ssDev) throw new Error('缺少 ssDev')
-      return `${base}?ssDev=${encodeURIComponent(ssDev)}`
+      const ssDevId = options.ssDevId || ''
+      if (!ssDevId) throw new Error('缺少 ssDevId')
+      return `${base}?ssDevId=${encodeURIComponent(ssDevId)}`
     }
 
     const ssToken = options.ssToken || ''
@@ -86,21 +95,34 @@ class WebSocketService {
   }
 
   async connect(options = {}) {
+    console.log('[WebSocket] connect called', {
+      isConnected: this.isConnected,
+      hasSocketTask: !!this.socketTask,
+      hasConnectingPromise: !!this.connectingPromise,
+      options,
+      stack: getShortStack()
+    })
     if (this.isConnected && this.socketTask && !options.forceReconnect) {
+      console.log('[WebSocket] connect skip: already connected')
       return true
     }
     if (this.connectingPromise) {
+      console.log('[WebSocket] connect reuse: existing connectingPromise')
       return this.connectingPromise
     }
 
     if (options.forceReconnect) {
       await this.disconnect()
     }
+    if (this.socketTask && !this.isConnected && !options.forceReconnect) {
+      console.warn('[WebSocket] found stale socketTask while disconnected, cleanup before reconnect')
+      await this.disconnect()
+    }
 
     const merged = {
       role: options.role || this.connectOptions?.role || 'parent',
       ssToken: options.ssToken || this.connectOptions?.ssToken || '',
-      ssDev: options.ssDev || this.connectOptions?.ssDev || '',
+      ssDevId: options.ssDevId || this.connectOptions?.ssDevId || '',
       heartbeat: options.heartbeat ?? this.connectOptions?.heartbeat ?? false,
       heartbeatInterval: options.heartbeatInterval || this.connectOptions?.heartbeatInterval || this.heartbeatInterval,
       autoReconnect: options.autoReconnect ?? this.connectOptions?.autoReconnect ?? true,
@@ -115,6 +137,10 @@ class WebSocketService {
     this.isManualClose = false
 
     const wsUrl = this.buildSocketUrl(merged)
+    console.log('[WebSocket] connecting...', {
+      wsUrl,
+      merged
+    })
     const socketApi = typeof wx !== 'undefined' ? wx : uni
     this.connectingPromise = new Promise((resolve, reject) => {
       let settled = false
@@ -141,6 +167,7 @@ class WebSocketService {
           this.stopHeartbeat()
         }
         this.emit('open', { url: wsUrl, options: merged })
+        console.log('[WebSocket] open:', wsUrl)
         resolveOnce(true)
       }
 
@@ -154,6 +181,10 @@ class WebSocketService {
       }
 
       const handleError = (error) => {
+        console.error('[WebSocket] error:', {
+          wsUrl,
+          error
+        })
         this.emit('error', error)
         if (!this.isConnected && this.connectingPromise) {
           this.connectingPromise = null
@@ -162,6 +193,13 @@ class WebSocketService {
       }
 
       const handleClose = (res) => {
+        console.log('[WebSocket] close:', {
+          wsUrl,
+          isManualClose: this.isManualClose,
+          reconnectEnabled: !!this.connectOptions?.autoReconnect,
+          reconnectAttempts: this.reconnectAttempts,
+          closeEvent: res
+        })
         this.isConnected = false
         this.stopHeartbeat()
         this.emit('close', res)
@@ -174,6 +212,7 @@ class WebSocketService {
       try {
         task = socketApi.connectSocket({ url: wsUrl })
       } catch (error) {
+        console.error('[WebSocket] connectSocket throw:', { wsUrl, error })
         this.connectingPromise = null
         rejectOnce(error)
         return
@@ -186,6 +225,7 @@ class WebSocketService {
         task.onMessage(handleMessage)
         task.onError(handleError)
         task.onClose(handleClose)
+        console.log('[WebSocket] using task-level socket handlers')
         return
       }
 
@@ -211,6 +251,7 @@ class WebSocketService {
       socketApi.onSocketMessage(messageHandler)
       socketApi.onSocketError(errorHandler)
       socketApi.onSocketClose(closeHandler)
+      console.log('[WebSocket] using global socket handlers')
       openTimeout = setTimeout(() => {
         this.connectingPromise = null
         rejectOnce(new Error('WebSocket 连接超时(未收到 onSocketOpen)'))
@@ -225,11 +266,21 @@ class WebSocketService {
     if (this.reconnectAttempts >= this.maxReconnectAttempts) return
 
     this.reconnectAttempts += 1
+    console.warn('[WebSocket] schedule reconnect', {
+      reconnectAttempts: this.reconnectAttempts,
+      maxReconnectAttempts: this.maxReconnectAttempts,
+      reconnectInterval: this.reconnectInterval,
+      connectOptions: this.connectOptions
+    })
     this.reconnectTimer = setTimeout(async () => {
       this.reconnectTimer = null
       try {
+        console.warn('[WebSocket] reconnecting now', {
+          reconnectAttempts: this.reconnectAttempts
+        })
         await this.connect(this.connectOptions || {})
       } catch (error) {
+        console.error('[WebSocket] reconnect failed', error)
         this.tryReconnect()
       }
     }, this.reconnectInterval)
@@ -237,19 +288,29 @@ class WebSocketService {
 
   startHeartbeat() {
     this.stopHeartbeat()
+    console.log('[WebSocket] heartbeat start', { heartbeatInterval: this.heartbeatInterval })
     this.heartbeatTimer = setInterval(() => {
       if (!this.isConnected) return
+      console.log('[WebSocket] heartbeat send', HEARTBEAT_CMD)
       this.send(HEARTBEAT_CMD).catch(() => {})
     }, this.heartbeatInterval)
   }
 
   stopHeartbeat() {
     if (!this.heartbeatTimer) return
+    console.log('[WebSocket] heartbeat stop', {
+      isConnected: this.isConnected,
+      stack: getShortStack()
+    })
     clearInterval(this.heartbeatTimer)
     this.heartbeatTimer = null
   }
 
   async disconnect() {
+    console.log('[WebSocket] disconnect called', {
+      hasSocketTask: !!this.socketTask,
+      useGlobalSocketApi: this.useGlobalSocketApi
+    })
     this.isManualClose = true
     this.stopHeartbeat()
 
@@ -339,6 +400,12 @@ class WebSocketService {
   }
 
   async ensureConnected(options = {}) {
+    console.log('[WebSocket] ensureConnected called', {
+      isConnected: this.isConnected,
+      hasSocketTask: !!this.socketTask,
+      options,
+      stack: getShortStack()
+    })
     if (this.isConnected && this.socketTask) return true
     await this.connect(options)
     return true