<template>
	<view class="message-page">
		<view class="chat-nav">
			<view class="chat-title-wrap">
				<picker
					v-if="parentChatMembers.length > 1"
					mode="selector"
					:range="parentChatMemberNames"
					:value="parentChatMemberIndex"
					@change="handleParentMemberChange"
				>
					<view class="chat-title-picker">
						<Icon lib="base" name="icon-down" size="36" color="#000000" />
						<text class="chat-title">{{ currentChatName }}</text>
					</view>
				</picker>
				<view v-else class="chat-title-picker single">
					<text class="chat-title">{{ currentChatName }}</text>
				</view>
			</view>
			<view class="chat-nav-right">
				<view class="chat-call-btn" @click="startVoiceCall">
					<Icon lib="base" name="icon-aud-bold" size="36" color="#3c4151" />
				</view>
				<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>

		<!-- 消息列表 -->
			<scroll-view
			class="message-list"
			scroll-y
			:scroll-anchoring="true"
			:scroll-into-view="scrollIntoView"
			:upper-threshold="180"
			@scroll="handleMessageListScroll"
			@scrolltoupper="handleHistoryReachTop"
			@touchstart="handleListTouchStart"
			@touchmove="handleListTouchMove"
			@touchend="handleListTouchEnd"
		>
			<view v-if="loadingMoreHistory" class="history-loading-tip">正在加载更多...</view>
			<view class="message-item" :class="message.direction" v-for="(message, index) in messages" :key="index"
				:id="'msg-' + index">
				<!-- 头像+时间区域 -->
				<view class="avatar-section">
					<image
						class="avatar"
						:class="getAvatarClass(message)"
						:src="getAvatarSrc(message)"
						@error="handleAvatarLoadError(message, index)"
						mode="aspectFill"
					></image>
					<text class="msg-time">{{ message.time || '--:--' }}</text>
				</view>

				<!-- 内容区域 -->
				<view class="content-section">
					<!-- 职务 | 姓名（职务先隐藏） -->
					<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">
						<!-- 文字消息 -->
						<view v-if="message.type === 'text'" class="text-message-wrapper">
							<!-- 接收的消息 -->
							<template v-if="message.direction === 'left'">
								<view class="text-message">
									<text>{{ message.content }}</text>
								</view>
							</template>
							<!-- 发送的消息 -->
							<template v-else>
								<view class="text-message">
									<text>{{ message.content }}</text>
								</view>
							</template>
						</view>

						<!-- 通话记录 -->
						<view v-if="message.type === 'call'" class="call-message">
							<image src="/static/icon/tingtong.png"></image>
							<text>通话时长 {{ message.duration }}</text>
						</view>

						<!-- 语音消息 -->
						<view
							v-if="message.type === 'voice'"
							class="voice-message"
							:style="getVoiceBubbleStyle(message)"
							@click.stop="toggleVoicePlayback(message, index)"
						>
							<image
								:src="message.direction === 'left' ? '/static/icon/guangbo.png' : '/static/icon/yinbo.png'">
							</image>
							<text>{{ formatVoiceBubbleText(message) }}</text>
							<view v-if="message.isRecording" class="recording-dot"></view>
						</view>

						<!-- 图片消息 -->
						<view v-if="message.type === 'image'" class="image-message" @click.stop="openImagePreview(message)">
							<image class="image-preview" :src="message.imageUrl || '/static/logo.png'" mode="aspectFill"></image>
						</view>

						<!-- 文件消息 -->
						<view v-if="message.type === 'file'" class="file-message" @click.stop="openFilePreview(message)">
							<Icon lib="base" name="icon-file" size="39" :color="iconColor" />
							<text class="file-name">{{ message.fileName }}</text>
						</view>

						<!-- 视频消息 -->
						<view v-if="message.type === 'video'" class="video-message" @click.stop="openVideoPreview(message, index)">
							<video
								class="video-cover"
								:src="message.videoUrl"
								muted
								:controls="false"
								:show-center-play-btn="false"
								:enable-progress-gesture="false"
								object-fit="cover"
							></video>
							<view class="video-play">
								<Icon lib="base" name="icon-vid" size="40" color="#ffffff" />
							</view>
						</view>
						<view
							v-if="message.direction === 'left' && message.needReceipt && message.receiptStatus !== 'read'"
							class="receipt-button"
							@click.stop="confirmRead(message, index)"
						>
							确认阅读
						</view>
					</view>

					<!-- 语音转文字区域（仅语音消息且有转文字内容时显示） -->
						<view v-if="message.type === 'voice' && message.voiceText" class="voice-text-section">
							<!-- 接收的消息 -->
							<template v-if="message.direction === 'left'">
								<view class="voice-text-content">{{ message.voiceText }}</view>
							</template>
							<!-- 发送的消息 -->
							<template v-else>
								<view class="voice-text-content">{{ message.voiceText }}</view>
							</template>
						</view>
					</view>
				</view>
			</scroll-view>

		<!-- 底部操作栏 -->
		<view class="footer" :class="{ active: showMorePanel || showEmojiPanel }" @click.stop>
			<!-- 左侧：语音/键盘切换 -->
			<view class="tool-btn" @click="toggleInputMode">
				<Icon lib="base" :name="inputMode === 'voice' ? 'icon-txt' : 'icon-spk'" size="36" :color="iconColor" />
			</view>

				<!-- 中间：输入框 / 按住说话 -->
				<view class="center-area">
					<view
						v-if="inputMode === 'voice'"
						class="press-to-talk"
						@touchstart="handlePressToTalkTouchStart"
						@longpress="handlePressToTalkStart"
						@touchmove.stop.prevent="handlePressToTalkMove"
						@touchend.stop.prevent="handlePressToTalkEnd"
						@touchcancel.stop.prevent="handlePressToTalkCancel"
					>
						<Icon lib="base" name="icon-aud" size="36" :color="iconColor" />
						<text class="press-text">长按说话</text>
					</view>
					<textarea
						v-else
						class="text-input"
						:class="{ capped: textLineCount >= 5 }"
						v-model="draftText"
						:focus="inputFocus"
						auto-height
						confirm-type="send"
						@confirm="sendTextMessage"
						maxlength="-1"
						@linechange="handleTextLineChange"
						@focus="handleTextInputFocus"
						@blur="inputFocus = false"
					/>
				</view>

				<!-- 右侧：表情 + 更多 -->
				<view class="tool-btn" @click="toggleEmojiPanel">
					<Icon lib="base" name="icon-emoji" size="36" :color="iconColor" />
				</view>
				<view class="tool-btn" @click="toggleMorePanel">
					<Icon lib="base" name="icon-add" size="36" :color="iconColor" />
				</view>
			</view>

		<!-- 底部扩展面板（更多/表情） -->
		<view class="bottom-panel" :class="{ open: showMorePanel || showEmojiPanel }" @click.stop>
			<view v-show="showMorePanel" class="more-panel">
				<view class="more-grid">
					<!-- <view class="more-item" @click="handleMoreAction('file')">
						<view class="more-icon">
							<Icon lib="base" name="icon-file" size="36" :color="iconColor" />
						</view>
						<text class="more-text">文件</text>
					</view> -->
					<view class="more-item" @click="handleMoreAction('image')">
						<view class="more-icon">
							<Icon lib="base" name="icon-img" size="36" :color="iconColor" />
						</view>
						<text class="more-text">照片</text>
					</view>
					<view class="more-item" @click="handleMoreAction('camera')">
						<view class="more-icon">
							<Icon lib="base" name="icon-photo" size="36" :color="iconColor" />
						</view>
						<text class="more-text">拍照</text>
					</view>
					<view class="more-item" @click="handleMoreAction('video')">
						<view class="more-icon">
							<Icon lib="base" name="icon-vid" size="36" :color="iconColor" />
						</view>
						<text class="more-text">视频留言</text>
					</view>
				</view>
			</view>

			<view v-show="showEmojiPanel" class="emoji-panel">
				<view class="emoji-grid">
					<view class="emoji-item" v-for="(emoji, index) in emojiList" :key="index"
						@click="insertEmoji(emoji)">
						{{ emoji }}
					</view>
					</view>
				</view>
			</view>

			<view v-if="showMediaPreview" class="media-preview-mask" @click="closeMediaPreview">
				<view class="media-preview-content" :class="previewType === 'image' ? 'image-mode' : 'video-mode'" @click.stop="handlePreviewContentClick">
					<swiper
						v-if="previewType === 'image'"
						class="media-preview-swiper"
						:current="previewImageIndex"
						@change="handleImageSwiperChange"
					>
						<swiper-item class="media-preview-swiper-item" v-for="(img, idx) in previewImageList" :key="idx">
							<image class="media-preview-image fit-x" :src="img" mode="widthFix"></image>
						</swiper-item>
					</swiper>
					<video
						v-if="previewType === 'video'"
						class="media-preview-video"
						:src="previewSource"
						autoplay
						controls
						object-fit="contain"
						@loadedmetadata="handlePreviewVideoLoaded"
						@error="handlePreviewVideoError"
					></video>
				</view>
				<view v-if="previewType === 'video'" class="media-preview-exit" @click.stop="closeMediaPreview">退出</view>
			</view>

			<view v-if="showFileDialog" class="file-dialog-mask" @click="closeFileDialog">
				<view class="file-dialog" @click.stop>
					<view class="file-dialog-header">
						<Icon lib="base" name="icon-file" size="44" :color="iconColor" />
						<text class="file-dialog-name">{{ activeFileName }}</text>
					</view>
					<view class="file-dialog-actions">
						<view class="file-dialog-btn ghost" @click="closeFileDialog">取消</view>
						<view class="file-dialog-btn" @click="downloadFileFromDialog">下载</view>
					</view>
				</view>
			</view>

			<view v-if="isRecording" class="record-mask">
				<view class="record-panel">
					<view class="record-title">正在录音</view>
					<view class="record-time">{{ recordSeconds }}s</view>
					<view class="record-tip" :class="{ danger: isRecordCancel }">
						{{ isRecordCancel ? '松开取消发送' : '手指上滑，取消发送' }}
					</view>
				</view>
			</view>

			<custom-modal
				:visible="modalVisible"
				:title="modalTitle"
				:content="modalContent"
				:showCancel="modalShowCancel"
				:confirmText="modalConfirmText"
				@confirm="handleModalConfirm"
				@cancel="handleModalCancel"
			/>

			<SsConfirm
				:visible="serviceConfirmVisible"
				title="提示"
				:showHeader="true"
				width="560rpx"
				height="420rpx"
				:bottom-buttons="serviceConfirmButtons"
				@button-click="handleServiceConfirmAction"
			>
				<view class="service-confirm-body">{{ serviceConfirmContent || '当前服务不可用' }}</view>
			</SsConfirm>
		</view>
	</template>

<script>
import Icon from '@/components/icon/index.vue'
import customModal from '@/components/custom-modal.vue'
import SsConfirm from '@/components/SsConfirm/index.vue'
import { EMOJI_LIST } from '@/constants/emoji'
import { collectImageUrls, createImageMessage, createTextMessage } from '@/utils/parent-message-factory'
import {
	buildTextOutgoingMessage,
	buildVoiceOutgoingMessage,
	pickFileOutgoingMessage,
	pickImageOutgoingMessages,
	pickVideoOutgoingMessage,
	shootImageOutgoingMessage
} from '@/utils/parent-message-send'
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
const REC_RYLBM_STUDENT = 1100
const REC_RYLBM_PARENT = 1200
const CALL_END_STORAGE_KEY = 'parentLastCallInfo'
const CALL_END_PAGE_URL = '/pages/parent/message'
const HISTORY_MORE_CMD = 162 // 分页加载旧消息命令

let wmpfVoip = null
try {
	if (typeof requirePlugin === 'function') {
		wmpfVoip = requirePlugin('wmpf-voip').default
	}
} catch (error) {
	console.warn('wmpf-voip 插件暂不可用', error)
}

const miniprogramState = (() => {
	if (typeof wx === 'undefined' || typeof wx.getAccountInfoSync !== 'function') {
		return 'formal'
	}
	const accountInfo = wx.getAccountInfoSync()
	if (accountInfo && accountInfo.miniProgram) {
		const platform = { develop: 'developer', trial: 'trial', release: 'formal' }
		return platform[accountInfo.miniProgram.envVersion]
	}
	return 'formal'
})()

	export default {
		props: {
			role: {
				type: String,
				default: ''
			},
			contactId: {
				type: [String, Number],
				default: ''
			},
			contactName: {
				type: String,
				default: ''
			},
			studentId: {
				type: [String, Number],
				default: ''
			}
		},
				components: {
					Icon,
					customModal,
					SsConfirm
				},
			computed: {},
			watch: {
				role() {
					this.initializeSessionFromOptions({ force: true, role: this.role, contactId: this.contactId, studentId: this.studentId })
					this.bootstrapMessageFlow()
				},
				contactId() {
					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()
				}
			},
			data() {
		return {
				scrollIntoView: '',
			inputMode: 'voice', // voice | text
			inputFocus: false,
			draftText: '',
			iconColor: '#575d6d',
			showEmojiPanel: false,
			showMorePanel: false,
			emojiList: EMOJI_LIST,
			showMediaPreview: false,
			previewType: '',
			previewSource: '',
			activeVideoIndex: -1,
			previewImageList: [],
			previewImageIndex: 0,
			showFileDialog: false,
				activeFileName: '',
				activeFileUrl: '',
				receiptToggleTimers: {},
				parentChatMembers: [],
				parentChatMemberNames: [],
				parentChatMemberIndex: 0,
				currentChatName: '留言',
				historyLoading: false,
				historyWaiter: null,
				historyWaiterTimer: null,
				historyMode: 'initial',
				historyBatchCount: 0,
				historyPageNo: 0,
				historyPendingMessages: [],
				loadingMoreHistory: false,
				hasMoreHistory: true,
				historyCursorMsgId: '',
				historyTopLoadArmed: true,
				parentInfo: {
					xm: '',
					openid: ''
				},
				currentCallContact: null,
				_voipEndPathSet: false,
				_voipEventRegistered: false,
				currentUserMeta: {
					direction: 'right',
					department: '家长',
				name: '家长'
			},
			textLineCount: 1,
			listTouchMoved: false,
			touchStartX: 0,
			touchStartY: 0,
			recorderManager: null,
			isRecording: false,
			isRecordCancel: false,
			recordSeconds: 0,
				recordStartY: 0,
				recordTickTimer: null,
				recordGuardTimer: null,
				recordPermissionPromise: null,
				isPressingToTalk: false,
				selfAvatarLoadFailed: false,
					audioPlayer: null,
					playingVoiceIndex: -1,
				sessionInitialized: false,
				sessionRole: 'parent',
				sessionContactId: '',
				sessionStudentId: '',
				entryFromDeviceIndex: false,
				socketConnected: false,
				socketUnsubscribeList: [],
				currentWsConfig: null,
				wsIdentity: {
					sendRyid: '',
					recRyid: '',
					recRylbm: REC_RYLBM_STUDENT
				},
				deviceSessionReady: true,
				deviceInitPromise: null,
				peerAvatarTypeHint: '',
				serviceStatus: null,
				inactivityTimer: null,
				inactivityTimeout: 180000,
				isCalling: false,
				modalVisible: false,
				modalTitle: '提示',
				modalContent: '',
				modalShowCancel: false,
				modalConfirmText: '确定',
				modalResolve: null,
				serviceConfirmVisible: false,
				serviceConfirmContent: '',
				serviceConfirmButtons: [{ text: '关闭' }],
				loadOptions: {},
				// 消息列表数据
				messages: []
				}
			},
	created() {
		this.initializeSessionFromOptions({
			role: this.role,
			contactId: this.contactId,
			contactName: this.contactName,
			studentId: this.studentId
		})
	},
	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
		}
	},
			async onReady() {
				// 页面渲染完成后，滚动到最新消息
				this.$nextTick(() => {
					this.scrollToBottom()
					this.initReceiptToggleTimers()
					this.initRecorder()
				})
				this.registerVoipEvent()
				this.bindSocketListeners()
				if (this.sessionRole === 'device') {
					await (this.deviceInitPromise || Promise.resolve(true))
					if (!this.deviceSessionReady) return
					this.resetInactivityTimer()
				}
				await this.checkServiceStatus()
				await this.bootstrapMessageFlow()
			},
			onUnload() {
				this.teardownSocket()
				this.stopVoicePlayback()
				if (this.audioPlayer) {
					this.audioPlayer.destroy()
					this.audioPlayer = null
				}
				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 = safeDecode(options.contactId || this.contactId || '')
					this.sessionStudentId = options.studentId || this.studentId || ''
					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
					if (this.socketUnsubscribeList.length) {
						this.bindSocketListeners()
					}
				},
				async bootstrapMessageFlow() {
					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) {
						uni.showToast({ title: '缺少设备标识，请重新刷卡登录', icon: 'none' })
						setTimeout(() => {
							uni.reLaunch({ url: '/pages/device/notice' })
						}, 1200)
						return false
					}
					if (!cardNo) {
						const hasSession = !!(userInfo.yhsbToken || userInfo.onlineToken || userInfo.sessId)
						if (!hasSession) {
							uni.showToast({ title: '登录已失效，请重新刷卡登录', icon: 'none' })
							setTimeout(() => {
								uni.reLaunch({ url: '/pages/device/notice' })
							}, 1200)
							return false
						}
						this.refreshCurrentUserMeta()
						this.resolveWsIdentity()
						this.currentWsConfig = this.buildWsConnectOptions()
						return true
					}
					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 || '',
							cardNo,
							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 {
						const result = await grfwApi.btc_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' })
					}
				},
				async loadDeviceChatMembers() {
					try {
						const result = await deviceApi.mp_telHomep_load()
						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]
					const result = []
					while (queue.length) {
						const item = queue.shift()
						if (Array.isArray(item)) {
							queue.unshift(...item)
							continue
						}
						if (item && typeof item === 'object') {
							result.push(item)
						}
					}
					return result
				},
				async handleParentMemberChange(event) {
					if (this.historyLoading) {
						await this.finishHistoryLoading()
					}
					const index = Number(event?.detail?.value || 0)
					console.log('handleParentMemberChange -> index', index)
					this.selectParentMemberByIndex(index)
					await this.loadHistoryMessages({ reset: true })
				},
				selectParentMemberByIndex(index = 0) {
					const safeIndex = Math.max(0, Math.min(index, this.parentChatMembers.length - 1))
					this.parentChatMemberIndex = safeIndex
					const target = this.parentChatMembers[safeIndex] || {}
					this.sessionContactId = String(target.ryid || '')
					this.currentChatName = target.xm || '留言'
					this.refreshCurrentUserMeta()
					this.resolveWsIdentity()
					this.currentWsConfig = this.buildWsConnectOptions()
				},
				initParentInfo() {
					let info = uni.getStorageSync('userInfo')
					if (typeof info === 'string') {
						try {
							info = JSON.parse(info)
						} catch (error) {
							info = null
						}
					}
					if (!info || typeof info !== 'object') return
					this.parentInfo = {
						...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 studentAvatar = this.normalizeMediaPath(userInfo.zjzwj)
					const parentAvatar = this.normalizeMediaPath(userInfo.yszwj)
					const peerType = String(this.peerAvatarTypeHint || '')
					const preferType = peerType === '1' ? '51' : (peerType === '51' ? '1' : '')
					if (preferType === '51') {
						return {
							url: this.toDisplayImageUrl(studentAvatar || parentAvatar || ''),
							type: '51'
						}
					}
					if (preferType === '1') {
						return {
							url: this.toDisplayImageUrl(parentAvatar || studentAvatar || ''),
							type: '1'
						}
					}
					if (this.sessionRole === 'device') {
						if (studentAvatar) {
							return {
								url: this.toDisplayImageUrl(studentAvatar),
								type: '51'
							}
						}
						if (parentAvatar) {
							return {
								url: this.toDisplayImageUrl(parentAvatar),
								type: '1'
							}
						}
						return {
							url: '/static/logo.png',
							type: ''
						}
					}
					if (parentAvatar) {
						return {
							url: this.toDisplayImageUrl(parentAvatar),
							type: '1'
						}
					}
					if (studentAvatar) {
						return {
							url: this.toDisplayImageUrl(studentAvatar),
							type: '51'
						}
					}
					return {
						url: '/static/logo.png',
						type: ''
					}
				},
				getAvatarSrc(message = {}) {
					if (!message || message.direction === 'right') {
						if (this.selfAvatarLoadFailed) return '/static/logo.png'
						return this.getSelfAvatarMeta().url
					}
					const peerAvatar = this.normalizeMediaPath(message.avatarUrl)
					return peerAvatar || '/static/logo.png'
				},
				handleAvatarLoadError(message, index) {
					const failedUrl = this.getAvatarSrc(message)
					console.warn('[Avatar] load failed:', failedUrl, 'direction:', message?.direction, 'index:', index)
					if (!message || message.direction === 'right') {
						this.selfAvatarLoadFailed = true
						return
					}
					if (typeof index === 'number' && this.messages[index]) {
						this.$set(this.messages[index], 'avatarUrl', '')
					}
				},
				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: this.toDisplayImageUrl(member.yszwj),
							openid: member.wbid2 || member.wbid || ''
						}
					}
					return {
						username: member.xm || member.username || '学生',
						avatar: this.toDisplayImageUrl(member.yszwj),
						deviceSn: member.deviceSn || member.sn || member.devId || member.sbkh || '',
						pushToken: member.pushToken || member.voipToken || member.token || ''
					}
				},
				async startVoiceCall() {
					if (!this.ensureFeatureAvailable(111)) return
					const contact = this.buildCallContactByCurrentMember()
					await this.startCall(contact, 'voice')
				},
				async startVideoCall() {
					if (!this.ensureFeatureAvailable(115)) return
					const contact = this.buildCallContactByCurrentMember()
					await this.startCall(contact, 'video')
				},
				async startCall(contact, roomType) {
					if (!wmpfVoip) {
						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
						}
						this.isCalling = true
						if (this.inactivityTimer) {
							clearTimeout(this.inactivityTimer)
							this.inactivityTimer = null
						}
						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
							}
							this.isCalling = false
							this.resetInactivityTimer()
							uni.hideLoading()
							uni.showToast({ title: '呼叫失败', icon: 'error' })
							return
						} catch (error) {
							this.isCalling = false
							this.resetInactivityTimer()
							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' })
						return
					}
					if (!contact?.deviceSn) {
						uni.showToast({ title: '未找到可呼叫的设备编号', icon: 'none' })
						return
					}
					if (!contact?.pushToken) {
						uni.showToast({ title: '设备未上报通话凭证', icon: 'none' })
						return
					}
					uni.showLoading({ title: '呼叫中...', mask: true })
					this.currentCallContact = contact
					const callInfo = {
						name: contact.username || '家庭设备',
						avatar: contact.avatar || '/static/logo.png',
						duration: 0,
						time: new Date().toLocaleString(),
						type: roomType,
						status: '呼叫中'
					}
					uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
					this.setVoipEndPagePath()
					try {
						const res = await wmpfVoip.initByCaller({
							roomType,
							caller: {
								id: callerId,
								name: this.parentInfo?.xm || '家长'
							},
							listener: {
								id: contact.deviceSn,
								name: contact.username
							},
							businessType: 2,
							voipToken: contact.pushToken,
							miniprogramState
						})
						if (res.isSuccess) {
							uni.hideLoading()
							uni.redirectTo({ url: wmpfVoip.CALL_PAGE_PATH })
							return
						}
						uni.hideLoading()
						console.error('呼叫失败', res)
						uni.showToast({ title: '呼叫失败', icon: 'error' })
					} catch (error) {
						uni.hideLoading()
						console.error('通话异常：', error)
						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.isCalling) return
					if (this.inactivityTimer) {
						clearTimeout(this.inactivityTimer)
					}
					this.inactivityTimer = setTimeout(() => {
						this.handleDeviceLogout()
					}, this.inactivityTimeout)
				},
				async checkServiceStatus() {
					try {
						const result = this.sessionRole === 'device'
							? await deviceApi.grfw_chkGrfw()
							: await grfwApi.grfw_chkGrfw()
						const data = result?.data || {}
						console.log('[ServiceStatus] grfw_chkGrfw raw:', data)
						if (data.ssCode === 0) {
							const sourceList = Array.isArray(data.ssData) ? data.ssData : []
							const statusMap = {}
							sourceList.forEach((item) => {
								if (!item || typeof item !== 'object') return
								Object.keys(item).forEach((serviceCode) => {
									const serviceValue = item[serviceCode]
									if (!serviceValue || typeof serviceValue !== 'object') return
									const stateCode = Object.keys(serviceValue)[0] || ''
									statusMap[String(serviceCode)] = {
										stateCode: String(stateCode),
										stateMsg: String(serviceValue[stateCode] || '')
									}
								})
							})
							this.serviceStatus = statusMap
							console.log('[ServiceStatus] parsed map:', this.serviceStatus)
						} else {
							console.warn('[ServiceStatus] non-zero ssCode:', data.ssCode, data.ssMsg || '')
						}
					} catch (error) {
						console.error('检查服务状态失败：', error)
					}
				},
				decodeStateMsg(raw = '') {
					const text = String(raw || '').trim()
					if (!text) return ''
					if (/[\u4e00-\u9fa5]/.test(text)) return text
					try {
						return decodeURIComponent(escape(text))
					} catch (error) {
						return text
					}
				},
				getFeatureLimitByCode(code) {
					const key = String(code || '')
					if (!key) return null
					if (!this.serviceStatus || typeof this.serviceStatus !== 'object') return null
					return this.serviceStatus[key] || null
				},
				ensureFeatureAvailable(code) {
					// 临时放开：当前阶段所有功能都视为可用，不做余额/订阅拦截。
					// 后续恢复时，取消下面注释并删除 return true 即可。
					// const limitInfo = this.getFeatureLimitByCode(code)
					// if (!limitInfo) return true
					// const stateMsg = this.decodeStateMsg(limitInfo.stateMsg || '当前服务不可用')
					// if (this.sessionRole === 'device') {
					// 	this.serviceConfirmButtons = [{ text: '关闭' }]
					// 	this.serviceConfirmContent = `${stateMsg}，请让家长订阅后再尝试`
					// } else {
					// 	this.serviceConfirmButtons = [{ text: '关闭' }, { text: '确定' }]
					// 	this.serviceConfirmContent = `${stateMsg}，请订阅后再尝试`
					// }
					// this.serviceConfirmVisible = true
					// return false
					void code
					return true
				},
				handleServiceConfirmAction(event) {
					const index = Number(event?.index ?? -1)
					this.serviceConfirmVisible = false
					if (this.sessionRole === 'parent' && index === 1) {
						uni.navigateTo({ url: '/pages/payment/recharge' })
					}
				},
				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()
					} 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 = [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 (userInfo.cardNo) {
							query.push(`cardNo=${encodeURIComponent(userInfo.cardNo)}`)
						}
					}
					if (callInfo.name) {
						query.push(`name=${encodeURIComponent(callInfo.name)}`)
						query.push(`avatar=${encodeURIComponent(callInfo.avatar || '')}`)
						query.push(`duration=${callInfo.duration || 0}`)
						query.push(`type=${callInfo.type || 'voice'}`)
						query.push(`status=${encodeURIComponent(callInfo.status || '通话已结束')}`)
					}
					wmpfVoip.setVoipEndPagePath({
						url: CALL_END_PAGE_URL,
						key: 'Call',
						options: query.join('&'),
						routeType: 'redirectTo'
					})
					this._voipEndPathSet = true
				},
				registerVoipEvent() {
					if (this._voipEventRegistered || !wmpfVoip) return
					wmpfVoip.onVoipEvent((event) => {
						const eventName = event.eventName
						console.log('[VoIP] event:', eventName, event.data || {})
						if (eventName === 'startVoip') {
							this.isCalling = true
							if (this.inactivityTimer) {
								clearTimeout(this.inactivityTimer)
								this.inactivityTimer = null
							}
						}
						const hangupEvent = ['hangUpVoip', 'endVoip']
						const cancelEvent = ['cancelVoip']
						const timeoutEvent = ['timeout']
						const rejectEvent = ['rejectVoip']
						const callInfo = uni.getStorageSync(CALL_END_STORAGE_KEY) || {}
						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 = '已取消'
						} else if (timeoutEvent.includes(eventName)) {
							callInfo.duration = 0
							callInfo.status = '未接听'
						} else if (rejectEvent.includes(eventName)) {
							callInfo.duration = 0
							callInfo.status = '已拒绝'
						}
						if ([...hangupEvent, ...cancelEvent, ...timeoutEvent, ...rejectEvent].includes(eventName)) {
							this.isCalling = false
							callInfo.endType = eventName
							callInfo.endTime = Date.now()
							uni.hideLoading()
							uni.setStorageSync(CALL_END_STORAGE_KEY, callInfo)
							this.setVoipEndPagePath(true)
							if (this.sessionRole === 'device') {
								let toastTitle = '通话已结束'
								if (cancelEvent.includes(eventName)) {
									toastTitle = '已取消呼叫'
								} else if (timeoutEvent.includes(eventName)) {
									toastTitle = '对方暂未接听'
								} else if (rejectEvent.includes(eventName)) {
									toastTitle = '对方已拒绝'
								} else if (hangupEvent.includes(eventName)) {
									const keepTime = Number(event?.data?.keepTime || 0)
									toastTitle = keepTime > 0 ? '通话已结束' : '未接通已结束'
								}
								uni.showToast({ title: toastTitle, icon: 'none' })
							}
							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 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')
					return `${hh}:${mm}`
				},
				waitHistoryDone(timeout = 12000) {
					if (this.historyWaiter) {
						return this.historyWaiter.promise
					}
					let resolve
					const promise = new Promise((r) => {
						resolve = r
					})
					this.historyWaiter = { promise, resolve }
					if (this.historyWaiterTimer) {
						clearTimeout(this.historyWaiterTimer)
					}
					this.historyWaiterTimer = setTimeout(() => {
						this.finishHistoryLoading()
					}, timeout)
					return promise
				},
				async finishHistoryLoading() {
					this.historyLoading = false
					this.loadingMoreHistory = false
					if (this.historyWaiterTimer) {
						clearTimeout(this.historyWaiterTimer)
						this.historyWaiterTimer = null
					}
					if (this.historyWaiter && typeof this.historyWaiter.resolve === 'function') {
						this.historyWaiter.resolve(true)
					}
					this.historyWaiter = null
					if (this.sessionRole === 'parent') {
						await websocketService.disconnect()
					}
				},
				async loadHistoryMessages({ reset = true, cmd = 161, mode = 'initial' } = {}) {
					if (this.historyLoading) return
					if (!this.wsIdentity.sendRyid || !this.wsIdentity.recRyid) {
						console.warn('loadHistoryMessages skip: missing wsIdentity', this.wsIdentity)
						return
					}
					console.log('loadHistoryMessages start', {
						sendRyid: this.wsIdentity.sendRyid,
						recRyid: this.wsIdentity.recRyid,
						role: this.sessionRole
					})
					if (reset) {
						this.messages = []
						this.scrollIntoView = ''
						this.peerAvatarTypeHint = ''
						this.hasMoreHistory = true
						this.historyCursorMsgId = ''
						this.historyPageNo = 0
						this.historyTopLoadArmed = true
					}

					const config = this.currentWsConfig || this.buildWsConnectOptions()
					this.historyLoading = true
					this.historyMode = mode
					this.historyBatchCount = 0
					this.historyPendingMessages = []
					this.loadingMoreHistory = mode === 'older'
					try {
						console.log('loadHistoryMessages ensureConnected start')
						await websocketService.ensureConnected(config)
						console.log('loadHistoryMessages ensureConnected done')
						const payload = {
							cmd,
							sendRyid: this.wsIdentity.sendRyid,
							recRyid: this.wsIdentity.recRyid,
							pageNo: mode === 'older' ? this.historyPageNo + 1 : 0
						}
						if (cmd === HISTORY_MORE_CMD && this.historyCursorMsgId) {
							payload.beforeXxid = this.historyCursorMsgId
							payload.lastXxid = this.historyCursorMsgId
						}
						console.log('[History] request payload:', payload)
						await websocketService.send(payload)
						await this.waitHistoryDone()
					} catch (error) {
						this.historyLoading = false
						this.loadingMoreHistory = false
						console.error('拉取历史留言失败:', error)
						if (this.sessionRole === 'parent') {
							await websocketService.disconnect()
						}
					}
				},
				resolveWsIdentity() {
					let userInfo = uni.getStorageSync('userInfo') || {}
					if (typeof userInfo === 'string') {
						try {
							userInfo = JSON.parse(userInfo)
						} catch (error) {
							userInfo = {}
						}
					}

					const sendRyid = String(userInfo.ryid || userInfo.yhid || userInfo.userId || '')
					const recRyid = String(
						this.sessionContactId ||
						this.sessionStudentId ||
						''
					)
					const recRylbm = this.sessionRole === 'parent' ? REC_RYLBM_STUDENT : REC_RYLBM_PARENT

					this.wsIdentity = {
						sendRyid,
						recRyid,
						recRylbm
					}
				},
				buildWsConnectOptions() {
					let userInfo = uni.getStorageSync('userInfo') || {}
					if (typeof userInfo === 'string') {
						try {
							userInfo = JSON.parse(userInfo)
						} catch (error) {
							userInfo = {}
						}
					}

					const config = {
						role: this.sessionRole
					}
					if (this.sessionRole === 'device') {
						config.ssDevId = String(userInfo.devId || '')
						config.heartbeat = true
						config.autoReconnect = true
					} else {
						config.ssToken = String(userInfo.yhsbToken || '')
						config.heartbeat = false
						config.autoReconnect = false
					}
					return config
				},
				bindSocketListeners() {
					if (!this.currentWsConfig) return
					this.teardownSocketListeners()
					const un1 = websocketService.on('open', () => {
						this.socketConnected = true
					})
					const un2 = websocketService.on('close', () => {
						this.socketConnected = false
					})
					const un3 = websocketService.on('error', () => {
						this.socketConnected = false
					})
					const un4 = websocketService.on('cmd:101', (payload) => {
						this.handleWsIncomingMessage(payload)
					})
					const un5 = websocketService.on('cmd:165', (payload) => {
						this.handleWsHistoryMessage(payload)
					})
					const un6 = websocketService.on('cmd:11', (payload) => {
						this.handleWsHistoryDone(payload)
					})
					const un7 = websocketService.on('cmd:151', (payload) => {
						this.handleWsReceipt(payload)
					})
					const un8 = websocketService.on('cmd:51', () => {
						uni.$emit('device-message-refresh')
					})
					this.socketUnsubscribeList = [un1, un2, un3, un4, un5, un6, un7, un8]
				},
				teardownSocketListeners() {
					if (!this.socketUnsubscribeList.length) return
					this.socketUnsubscribeList.forEach((off) => {
						if (typeof off === 'function') off()
					})
					this.socketUnsubscribeList = []
				},
				async teardownSocket() {
					this.teardownSocketListeners()
					this.socketConnected = false
					if (this.sessionRole !== 'device') {
						await websocketService.disconnect()
					}
				},
				async ensureSessionSocket() {
					if (!this.currentWsConfig) {
						this.initializeSessionFromOptions({ force: true })
					}
					let config = this.currentWsConfig || {}
					if (config.role === 'parent') return
					if (!config.ssDevId) {
						this.resolveWsIdentity()
						this.currentWsConfig = this.buildWsConnectOptions()
						config = this.currentWsConfig || {}
					}
					if (!config.ssDevId) return
					try {
						await websocketService.ensureConnected(config)
					} catch (error) {
						console.error('设备端 WS 连接失败:', error)
					}
				},
				normalizeMediaPath(path) {
					const text = String(path || '').trim()
					if (!text) return ''
					const lowerText = text.toLowerCase()
					if (lowerText === 'null' || lowerText === 'undefined') return ''
					return text
				},
				toDisplayImageUrl(path) {
					const safePath = this.normalizeMediaPath(path)
					if (!safePath) return '/static/logo.png'
					if (/^https?:\/\//.test(safePath)) return safePath
					return getImageUrl(safePath)
				},
				toDisplayFileUrl(path, type = '') {
					if (!path) return ''
					if (/^https?:\/\//.test(path)) return path
					const typePart = type ? `&type=${encodeURIComponent(type)}` : ''
					return `${env.baseUrl}/service?ssServ=dlByHttp&wdConfirmationCaptchaService=0${typePart}&path=${encodeURIComponent(path)}`
				},
				formatVoiceBubbleText(message = {}) {
					const duration = String(message.duration || '').trim()
					return duration ? `${duration}"` : '语音'
				},
				getBaseNameFromPath(path) {
					if (!path) return ''
					const source = String(path).split('?')[0]
					const seg = source.split('/')
					return seg[seg.length - 1] || ''
				},
				normalizeIncomingCont(cont) {
					if (!cont) return {}
					if (typeof cont === 'string') {
						try {
							return JSON.parse(cont)
						} catch (error) {
							return { body: cont }
						}
					}
					return cont
				},
				appendPayloadMessage(payload = {}, { fromHistory = false, prepend = false, collectOnly = false } = {}) {
					if (!this.shouldHandleIncomingPayload(payload)) return false
					const cont = this.normalizeIncomingCont(payload.cont)
					const typeCode = String(cont.type || '121')
					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 false
						if (collectOnly) {
							const inPending = this.historyPendingMessages.some((item) => String(item.msgId || '') === String(msgId))
							if (inPending) return false
						}
					}
					if (fromHistory && msgId) {
						const numMsgId = Number(msgId)
						if (!Number.isNaN(numMsgId) && numMsgId > 0) {
							if (!this.historyCursorMsgId || numMsgId < Number(this.historyCursorMsgId)) {
								this.historyCursorMsgId = String(numMsgId)
							}
						} else if (!this.historyCursorMsgId) {
							this.historyCursorMsgId = String(msgId)
						}
					}
					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'
					const pushMessage = (message) => {
						if (!message) return false
						if (collectOnly) {
							this.historyPendingMessages.push(message)
							return true
						}
						if (prepend) {
							this.prependMessage(message)
						} else {
							this.appendMessage(message)
						}
						return true
					}

					if (typeCode === '121') {
						const rawBody = String(cont.body || '').trim()
						if (cont.fileName && (!rawBody || /^\[文件\]/.test(rawBody))) {
							return pushMessage({
								type: 'file',
								direction,
								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: displayTime,
								needReceipt: true,
								receiptStatus,
								msgId
							})
						}
						return pushMessage(createTextMessage({
							direction,
							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: displayTime,
							msgId
						}))
					}

					if (typeCode === '122') {
						return pushMessage(createImageMessage({
							direction,
							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: displayTime,
							needReceipt: true,
							receiptStatus,
							msgId
						}))
					}

					if (typeCode === '123') {
						return pushMessage({
							type: 'voice',
							direction,
							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: displayTime,
							msgId
						})
					}

					if (typeCode === '124') {
						return pushMessage({
							type: 'video',
							direction,
							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: displayTime,
							needReceipt: true,
							receiptStatus,
							msgId
						})
					}
					return false
				},
				handleWsIncomingMessage(payload = {}) {
					this.appendPayloadMessage(payload, { fromHistory: false })
				},
				handleWsHistoryMessage(payload = {}) {
					const inserted = this.appendPayloadMessage(payload, {
						fromHistory: true,
						prepend: this.historyMode === 'older',
						collectOnly: this.historyMode === 'older'
					})
					if (inserted) {
						this.historyBatchCount += 1
					}
				},
				handleWsHistoryDone(payload = {}) {
					if (!this.historyLoading) return
					const pkgNum = Number(payload?.pkgNum || 0)
					const pageNo = Number(payload?.pageNo || 0)
					console.log('[History] done:', {
						historyMode: this.historyMode,
						historyBatchCount: this.historyBatchCount,
						pkgNum,
						pageNo,
						historyPageNo: this.historyPageNo
					})
					if (this.historyMode === 'older') {
						if (this.historyBatchCount > 0) {
							if (this.historyPendingMessages.length) {
								this.prependMessages(this.historyPendingMessages)
							}
							this.scrollIntoView = `msg-${this.historyBatchCount}`
							this.historyPageNo += 1
						}
						if (pkgNum > 0) {
							this.hasMoreHistory = pkgNum >= 20
						} else if (this.historyBatchCount < 20) {
							this.hasMoreHistory = false
						}
					}
					this.finishHistoryLoading()
				},
				shouldHandleIncomingPayload(payload = {}) {
					const send = String(payload.sendRyid || '')
					const rec = String(payload.recRyid || '')
					const me = String(this.wsIdentity.sendRyid || '')
					const peer = String(this.wsIdentity.recRyid || '')
					if (!me || !peer) return true
					if (!send || !rec) return true
					return (send === me && rec === peer) || (send === peer && rec === me)
				},
				handleWsReceipt(payload = {}) {
					const msgId = String(payload.xxid || '')
					if (!msgId) return
					const idx = this.messages.findIndex((item) => String(item.msgId || '') === msgId)
					if (idx < 0) return
					this.$set(this.messages[idx], 'receiptStatus', 'read')
				},
				async confirmRead(message, index) {
					if (!message || message.direction !== 'left') return
					if (message.receiptStatus === 'read') return
					const msgId = String(message.msgId || '')
					if (!msgId) return
					const payload = {
						cmd: 151,
						echoState: 2,
						xxid: msgId,
						sendRyid: this.wsIdentity.sendRyid,
						recRyid: this.wsIdentity.recRyid,
						recRylbm: this.wsIdentity.recRylbm
					}
					const sent = await this.sendWsPayload(payload)
					if (!sent) return
					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.ssDevId) {
						uni.showToast({ title: '缺少 ssDevId', icon: 'none' })
						return false
					}

					try {
						await websocketService.ensureConnected(config)
						await websocketService.send(payload)
						if (config.role === 'parent') {
							await websocketService.disconnect()
						}
						return true
					} catch (error) {
						console.error('发送留言失败:', error)
						uni.showToast({ title: '发送失败', icon: 'none' })
						return false
					}
				},
				async sendOutgoingByType(message) {
					if (!message) return false
					const typeCodeMap = {
						text: 121,
						image: 122,
						voice: 123,
						video: 124
					}
					const featureCode = typeCodeMap[message.type]
					if (featureCode && !this.ensureFeatureAvailable(featureCode)) {
						return false
					}
					const sendRyid = this.wsIdentity.sendRyid
					const recRyid = this.wsIdentity.recRyid
					if (!sendRyid || !recRyid) {
						uni.showToast({ title: '缺少人员标识', icon: 'none' })
						return false
					}

					let cont = null
					if (message.type === 'text') {
						cont = {
							title: '',
							type: '121',
							body: message.content || ''
						}
					} else if (message.type === 'image') {
						cont = {
							title: '',
							type: '122',
							body: '',
							fileName: message.serverFilePath || '',
							baseName: message.baseName || 'image.jpg'
						}
					} else if (message.type === 'voice') {
						cont = {
							title: '音频',
							type: '123',
							body: '',
							fileName: message.serverFilePath || '',
							baseName: message.baseName || 'voice.mp3',
							duration: message.duration || ''
						}
					} else if (message.type === 'video') {
						cont = {
							title: '视频',
							type: '124',
							body: '',
							fileName: message.serverFilePath || '',
							baseName: message.baseName || 'video.mp4',
							duration: message.duration || ''
						}
					} else if (message.type === 'file') {
						cont = {
							title: '',
							type: '121',
							body: '',
							fileName: message.serverFilePath || '',
							baseName: message.baseName || message.fileName || 'file'
						}
					} else {
						uni.showToast({ title: '当前消息类型不支持发送', icon: 'none' })
						return false
					}

					const payload = {
						cmd: 101,
						cont,
						sendRyid,
						recRyid,
						recRylbm: this.wsIdentity.recRylbm,
						echoState: 1
					}
					return this.sendWsPayload(payload)
				},
			getVoiceBubbleStyle(message) {
				if (!message || !message.isPlaying) return {}
				const progress = Math.max(0, Math.min(100, Number(message.playProgress || 0)))
				if (message.direction === 'right') {
					return {
						backgroundImage: `linear-gradient(to right, #7d89b1 ${progress}%, #eeeeee ${progress}%)`
					}
				}
				return {
					backgroundImage: `linear-gradient(to right, #eeeeee ${progress}%, #7d89b1 ${progress}%)`
				}
			},
			resetVoiceItemState(index) {
				if (index < 0 || !this.messages[index]) return
				this.$set(this.messages[index], 'isPlaying', false)
				this.$set(this.messages[index], 'playProgress', 0)
			},
			updateVoicePlayProgress() {
				const index = this.playingVoiceIndex
				if (index < 0 || !this.messages[index] || !this.audioPlayer) return
				const duration = Number(this.audioPlayer.duration || 0)
				const currentTime = Number(this.audioPlayer.currentTime || 0)
				if (duration <= 0) return
				const progress = Math.max(0, Math.min(100, (currentTime / duration) * 100))
				this.$set(this.messages[index], 'playProgress', progress)
			},
			initAudioPlayer() {
				if (this.audioPlayer || !uni.createInnerAudioContext) return
				this.audioPlayer = uni.createInnerAudioContext()
				this.audioPlayer.onTimeUpdate(() => {
					this.updateVoicePlayProgress()
				})
				this.audioPlayer.onEnded(() => {
					this.resetVoiceItemState(this.playingVoiceIndex)
					this.playingVoiceIndex = -1
				})
				this.audioPlayer.onStop(() => {
					this.resetVoiceItemState(this.playingVoiceIndex)
					this.playingVoiceIndex = -1
				})
				this.audioPlayer.onError(() => {
					this.resetVoiceItemState(this.playingVoiceIndex)
					this.playingVoiceIndex = -1
					uni.showToast({ title: '语音播放失败', icon: 'none' })
				})
			},
			stopVoicePlayback() {
				if (this.audioPlayer) {
					this.audioPlayer.stop()
				}
				this.resetVoiceItemState(this.playingVoiceIndex)
				this.playingVoiceIndex = -1
			},
			toggleVoicePlayback(message, index) {
				if (!message || message.type !== 'voice') return
				if (!message.audioUrl) {
					uni.showToast({ title: '该语音暂无音频文件', icon: 'none' })
					return
				}
				this.initAudioPlayer()
				if (!this.audioPlayer) {
					uni.showToast({ title: '当前环境不支持播放', icon: 'none' })
					return
				}
				if (this.playingVoiceIndex === index) {
					this.stopVoicePlayback()
					return
				}
				if (this.playingVoiceIndex > -1) {
					this.audioPlayer.stop()
					this.resetVoiceItemState(this.playingVoiceIndex)
				}
				this.playingVoiceIndex = index
				this.$set(this.messages[index], 'isPlaying', true)
				this.$set(this.messages[index], 'playProgress', 0)
				this.audioPlayer.src = message.audioUrl
				this.audioPlayer.play()
			},
			initRecorder() {
				if (!uni.getRecorderManager) return
				this.recorderManager = uni.getRecorderManager()
				this.recorderManager.onStop((res) => {
					const canceled = this.isRecordCancel
					const durationMs = Number(res && res.duration ? res.duration : 0)
					const durationSeconds = Math.max(1, Math.round(durationMs / 1000) || this.recordSeconds)
					this.clearRecordTimers()
					this.isRecording = false
					this.isRecordCancel = false
					this.recordStartY = 0

					if (canceled) {
						uni.showToast({ title: '已取消发送', icon: 'none' })
						return
					}
						if (!res || !res.tempFilePath || durationSeconds < 1) {
							uni.showToast({ title: '录音时间太短', icon: 'none' })
							return
						}

						const voiceMessage = buildVoiceOutgoingMessage({
							durationSeconds,
							audioUrl: res.tempFilePath,
							voiceText: ''
						}, this.currentUserMeta)
						if (!voiceMessage) return
						;(async () => {
							try {
								const localPath = voiceMessage.audioUrl
								const serverPath = await upload.uploadAudio(localPath)
								if (!serverPath) return
								voiceMessage.serverFilePath = serverPath
								voiceMessage.baseName = this.getBaseNameFromPath(localPath) || 'voice.mp3'
								voiceMessage.audioUrl = this.toDisplayFileUrl(serverPath, 'aud')
								const sent = await this.sendOutgoingByType(voiceMessage)
								if (sent) this.appendMessage(voiceMessage)
							} catch (error) {
								uni.showToast({ title: '语音发送失败', icon: 'none' })
							}
						})()
					})
				this.recorderManager.onError(() => {
					this.clearRecordTimers()
					this.isRecording = false
					this.isRecordCancel = false
					uni.showToast({ title: '录音失败', icon: 'none' })
				})
			},
			clearRecordTimers() {
				if (this.recordTickTimer) {
					clearInterval(this.recordTickTimer)
					this.recordTickTimer = null
				}
				if (this.recordGuardTimer) {
					clearTimeout(this.recordGuardTimer)
					this.recordGuardTimer = null
				}
			},
			cleanupRecorder() {
				this.clearRecordTimers()
				if (this.isRecording && this.recorderManager) {
					this.isRecordCancel = true
					try {
						this.recorderManager.stop()
					} catch (error) {
						// noop
					}
				}
				this.isRecording = false
				this.recordSeconds = 0
			},
			async ensureRecordPermission() {
				if (this.recordPermissionPromise) return this.recordPermissionPromise
				const permissionPromise = new Promise((resolve) => {
					uni.getSetting({
						success: (settingRes) => {
							const authSetting = settingRes && settingRes.authSetting ? settingRes.authSetting : {}
							const hasRecordAuth = authSetting['scope.record']
							console.log('[RecordPermission] getSetting authSetting:', authSetting, 'scope.record =', hasRecordAuth)
							if (hasRecordAuth === true) {
								console.log('[RecordPermission] already authorized')
								resolve(true)
								return
							}
							if (hasRecordAuth === false) {
								console.log('[RecordPermission] previously denied, openSetting required')
								uni.showModal({
									title: '提示',
									content: '请先开启录音权限后再使用语音留言',
									confirmText: '去授权',
									cancelText: '取消',
									success: (modalRes) => {
										if (!modalRes.confirm) {
											resolve(false)
											return
										}
										uni.openSetting({
											success: (openRes) => {
												const openAuthSetting = openRes && openRes.authSetting ? openRes.authSetting : {}
												console.log('[RecordPermission] openSetting authSetting:', openAuthSetting, 'scope.record =', openAuthSetting['scope.record'])
												resolve(!!openAuthSetting['scope.record'])
											},
											fail: (error) => {
												console.error('[RecordPermission] openSetting fail:', error)
												resolve(false)
											}
										})
									},
									fail: (error) => {
										console.error('[RecordPermission] showModal fail:', error)
										resolve(false)
									}
								})
								return
							}
							uni.authorize({
								scope: 'scope.record',
								success: () => {
									console.log('[RecordPermission] authorize success')
									resolve(true)
								},
								fail: () => {
									console.warn('[RecordPermission] authorize denied')
									uni.showToast({ title: '请先开启录音权限', icon: 'none' })
									resolve(false)
								}
							})
						},
						fail: (error) => {
							console.error('[RecordPermission] getSetting fail:', error)
							resolve(false)
						}
					})
				})
				this.recordPermissionPromise = permissionPromise.finally(() => {
					this.recordPermissionPromise = null
				})
				return this.recordPermissionPromise
			},
			handlePressToTalkTouchStart() {
				if (this.isRecording) return
				this.isPressingToTalk = true
				this.ensureRecordPermission().catch(() => {})
			},
			async handlePressToTalkStart(event) {
				if (this.isRecording) return
				if (!this.ensureFeatureAvailable(123)) return
				const hasRecordPermission = await this.ensureRecordPermission()
				if (!hasRecordPermission) return
				if (!this.isPressingToTalk) return
				if (!this.recorderManager) {
					this.initRecorder()
				}
				if (!this.recorderManager) {
					uni.showToast({ title: '当前环境不支持录音', icon: 'none' })
					return
				}

				const touch = event && event.touches && event.touches[0]
				this.recordStartY = touch ? touch.clientY : 0
				this.recordSeconds = 0
				this.isRecordCancel = false
				this.isRecording = true
				this.clearRecordTimers()

				this.recordTickTimer = setInterval(() => {
					this.recordSeconds = Math.min(60, this.recordSeconds + 1)
				}, 1000)
				this.recordGuardTimer = setTimeout(() => {
					if (!this.isRecording || !this.recorderManager) return
					this.recorderManager.stop()
				}, 60000)

				try {
					this.recorderManager.start({
						duration: 60000,
						sampleRate: 16000,
						numberOfChannels: 1,
						encodeBitRate: 96000,
						format: 'mp3'
					})
				} catch (error) {
					this.clearRecordTimers()
					this.isRecording = false
					this.isRecordCancel = false
					uni.showToast({ title: '录音启动失败', icon: 'none' })
				}
			},
			handlePressToTalkMove(event) {
				if (!this.isRecording) return
				const touch = event && event.touches && event.touches[0]
				if (!touch) return
				const deltaY = this.recordStartY - touch.clientY
				this.isRecordCancel = deltaY > 80
			},
			handlePressToTalkEnd() {
				this.isPressingToTalk = false
				if (!this.isRecording || !this.recorderManager) return
				this.recorderManager.stop()
			},
			handlePressToTalkCancel() {
				this.isPressingToTalk = false
				if (!this.isRecording || !this.recorderManager) return
				this.isRecordCancel = true
				this.recorderManager.stop()
			},
			back() {
			uni.navigateBack({
				delta: 1
			})
		},
		// 切换带回执状态
		toggleReceipt(index, value) {
			this.messages[index].needReceipt = value === 1 || value === '1'
		},
		initReceiptToggleTimers() {
			this.messages.forEach((message, index) => {
				if (message.showReceiptToggle) {
					this.registerReceiptToggleTimer(index)
				}
			})
		},
		registerReceiptToggleTimer(index) {
			const message = this.messages[index]
			if (!message || !message.showReceiptToggle) return

			if (!message.receiptToggleCreatedAt) {
				message.receiptToggleCreatedAt = Date.now()
			}

			const expireAt = message.receiptToggleCreatedAt + RECEIPT_TOGGLE_TTL_MS
			const remain = expireAt - Date.now()

			if (remain <= 0) {
				message.showReceiptToggle = false
				this.clearReceiptToggleTimer(index)
				return
			}

			this.clearReceiptToggleTimer(index)
			this.receiptToggleTimers[index] = setTimeout(() => {
				const target = this.messages[index]
				if (target) {
					target.showReceiptToggle = false
				}
				this.clearReceiptToggleTimer(index)
			}, remain)
		},
		clearReceiptToggleTimer(index) {
			const timerId = this.receiptToggleTimers[index]
			if (timerId) {
				clearTimeout(timerId)
				delete this.receiptToggleTimers[index]
			}
		},
		clearReceiptToggleTimers() {
			Object.keys(this.receiptToggleTimers).forEach((key) => {
				this.clearReceiptToggleTimer(key)
			})
		},
		// 滚动到底部
		scrollToBottom() {
			const lastIndex = this.messages.length - 1
			if (lastIndex >= 0) {
				this.scrollIntoView = 'msg-' + lastIndex
			}
		},
		toggleInputMode() {
			if (this.inputMode === 'voice') {
				if (!this.ensureFeatureAvailable(121)) return
				this.inputMode = 'text'
				this.showMorePanel = false
				this.showEmojiPanel = false
				this.$nextTick(() => {
					this.inputFocus = true
				})
			} else {
				this.inputMode = 'voice'
				this.showMorePanel = false
				this.showEmojiPanel = false
				this.inputFocus = false
			}
		},
		toggleEmojiPanel() {
			const nextStatus = !this.showEmojiPanel
			this.showEmojiPanel = nextStatus
			if (nextStatus) {
				this.inputMode = 'text'
				this.showMorePanel = false
				this.inputFocus = false
			}
		},
		toggleMorePanel() {
			const nextStatus = !this.showMorePanel
			this.showMorePanel = nextStatus
			if (nextStatus) {
				this.inputMode = 'text'
				this.showEmojiPanel = false
				this.inputFocus = false
			}
		},
		handlePageClick() {
			if (this.showMorePanel || this.showEmojiPanel) {
				this.showMorePanel = false
				this.showEmojiPanel = false
			}
		},
		insertEmoji(emoji) {
			this.inputMode = 'text'
			this.draftText = `${this.draftText}${emoji}`
		},
		handleListTouchStart(event) {
			this.resetInactivityTimer()
			const touch = event.touches && event.touches[0]
			if (!touch) return
			this.listTouchMoved = false
			this.touchStartX = touch.clientX
			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)
			const deltaY = Math.abs(touch.clientY - this.touchStartY)
			if (deltaX > 8 || deltaY > 8) {
				this.listTouchMoved = true
			}
		},
			handleListTouchEnd() {
				this.resetInactivityTimer()
				if (!this.listTouchMoved) {
					this.handlePageClick()
				}
			},
			handleMessageListScroll(event) {
				const scrollTop = Number(event?.detail?.scrollTop || 0)
				if (scrollTop > 180) {
					this.historyTopLoadArmed = true
				}
				if (scrollTop > 120) return
				this.handleHistoryReachTop()
			},
			async handleHistoryReachTop() {
				this.resetInactivityTimer()
				if (!this.historyTopLoadArmed) return
				if (this.historyLoading || this.loadingMoreHistory) return
				if (!this.hasMoreHistory) return
				if (!this.messages.length) return
				this.historyTopLoadArmed = false
				await this.loadHistoryMessages({
					reset: false,
					cmd: HISTORY_MORE_CMD,
					mode: 'older'
				})
			},
			prependMessages(newMessages = []) {
				if (!newMessages.length) return
				this.messages = [...newMessages, ...this.messages]
			},
			prependMessage(newMessage) {
				if (!newMessage) return
				this.prependMessages([newMessage])
			},
			appendMessages(newMessages = []) {
				if (!newMessages.length) return
				const startIndex = this.messages.length
				this.messages = [...this.messages, ...newMessages]
				newMessages.forEach((message, offset) => {
					if (message.showReceiptToggle) {
						this.registerReceiptToggleTimer(startIndex + offset)
					}
				})
				this.$nextTick(() => {
					this.scrollToBottom()
				})
			},
			appendMessage(newMessage) {
				if (!newMessage) return
				this.appendMessages([newMessage])
			},
			async handleMoreAction(type) {
				try {
					const typeCodeMap = {
						image: 122,
						camera: 122,
						video: 124
					}
					const featureCode = typeCodeMap[type]
					if (featureCode && !this.ensureFeatureAvailable(featureCode)) {
						return
					}
					if (type === 'file') {
						const fileMessage = await pickFileOutgoingMessage(this.currentUserMeta)
						if (!fileMessage) return
						const localPath = fileMessage.fileUrl
						const serverPath = await upload.uploadCommonFile(localPath)
						if (!serverPath) return
						fileMessage.serverFilePath = serverPath
						fileMessage.baseName = fileMessage.fileName || this.getBaseNameFromPath(localPath) || 'file'
						fileMessage.fileUrl = this.toDisplayFileUrl(serverPath)
						const sent = await this.sendOutgoingByType(fileMessage)
						if (sent) this.appendMessage(fileMessage)
						return
					}
					if (type === 'image') {
						const imageMessages = await pickImageOutgoingMessages(this.currentUserMeta)
						for (const imageMessage of imageMessages) {
							const localPath = imageMessage.imageUrl
							const serverPath = await upload.uploadImage(localPath)
							if (!serverPath) continue
							imageMessage.serverFilePath = serverPath
							imageMessage.baseName = localPath.split('/').pop() || 'image.jpg'
							imageMessage.imageUrl = this.toDisplayImageUrl(serverPath)
							const sent = await this.sendOutgoingByType(imageMessage)
							if (sent) this.appendMessage(imageMessage)
						}
						return
					}
					if (type === 'camera') {
						const imageMessage = await shootImageOutgoingMessage(this.currentUserMeta)
						if (!imageMessage) return
						const localPath = imageMessage.imageUrl
						const serverPath = await upload.uploadImage(localPath)
						if (!serverPath) return
						imageMessage.serverFilePath = serverPath
						imageMessage.baseName = localPath.split('/').pop() || 'image.jpg'
						imageMessage.imageUrl = this.toDisplayImageUrl(serverPath)
						const sent = await this.sendOutgoingByType(imageMessage)
						if (sent) this.appendMessage(imageMessage)
						return
					}
					if (type === 'video') {
						const videoMessage = await pickVideoOutgoingMessage(this.currentUserMeta)
						if (!videoMessage) return
						const localPath = videoMessage.videoUrl
						const serverPath = await upload.uploadVideo(localPath)
						if (!serverPath) return
						videoMessage.serverFilePath = serverPath
						videoMessage.baseName = this.getBaseNameFromPath(localPath) || 'video.mp4'
						videoMessage.videoUrl = this.toDisplayFileUrl(serverPath, 'vid')
						const sent = await this.sendOutgoingByType(videoMessage)
						if (sent) this.appendMessage(videoMessage)
						return
					}
				uni.showToast({ title: `点击：${type}`, icon: 'none' })
			} catch (error) {
				uni.showToast({ title: '操作已取消', icon: 'none' })
			}
		},
		openImagePreview(message) {
			const imageUrls = collectImageUrls(this.messages)
			if (!imageUrls.length) return
			const currentUrl = message.imageUrl || imageUrls[0]
			const currentIndex = imageUrls.findIndex((item) => item === currentUrl)
			this.previewType = 'image'
			this.previewImageList = imageUrls
			this.previewImageIndex = currentIndex > -1 ? currentIndex : 0
			this.showMediaPreview = true
		},
		openVideoPreview(message, index) {
			this.previewType = 'video'
			this.previewSource = message.videoUrl || message.coverUrl || '/static/logo.png'
			this.activeVideoIndex = typeof index === 'number' ? index : -1
			this.showMediaPreview = true
		},
		openFilePreview(message) {
			this.activeFileName = message.fileName || '未命名文件'
			this.activeFileUrl = message.fileUrl || ''
			this.showFileDialog = true
		},
		downloadFileFromDialog() {
			if (!this.activeFileUrl) {
				uni.showToast({
					title: '文件地址不存在',
					icon: 'none'
				})
				return
			}
			const lowerUrl = (this.activeFileUrl || '').toLowerCase()
			const lowerName = (this.activeFileName || '').toLowerCase()
			const target = lowerName || lowerUrl
			const isDocType = /(\.pdf|\.doc|\.docx|\.xls|\.xlsx|\.ppt|\.pptx|\.txt)$/.test(target)
			uni.downloadFile({
				url: this.activeFileUrl,
					success: (res) => {
						if (res.statusCode === 200) {
							this.closeFileDialog()
							if (isDocType) {
								uni.openDocument({
									filePath: res.tempFilePath,
									showMenu: true,
									fail: () => {
									uni.showToast({
										title: '文件打开失败',
										icon: 'none'
									})
								}
							})
						} else {
							uni.showToast({
								title: '下载成功，当前类型不支持在线打开',
								icon: 'none'
							})
						}
					} else {
						uni.showToast({
							title: '下载失败',
							icon: 'none'
						})
					}
				},
				fail: () => {
					uni.showToast({
						title: '下载失败',
						icon: 'none'
					})
				}
			})
		},
		closeFileDialog() {
			this.showFileDialog = false
			this.activeFileName = ''
			this.activeFileUrl = ''
		},
		closeMediaPreview() {
			this.showMediaPreview = false
			this.previewType = ''
			this.previewSource = ''
			this.activeVideoIndex = -1
			this.previewImageList = []
			this.previewImageIndex = 0
		},
		handlePreviewVideoLoaded(event) {
			const seconds = event?.detail?.duration
			if (!seconds || this.activeVideoIndex < 0 || !this.messages[this.activeVideoIndex]) return
			const mins = Math.floor(seconds / 60)
			const secs = Math.floor(seconds % 60)
			const durationText = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
			this.messages[this.activeVideoIndex].duration = durationText
		},
		handlePreviewContentClick() {
			if (this.previewType === 'image') {
				this.closeMediaPreview()
			}
		},
		handleImageSwiperChange(event) {
			this.previewImageIndex = event.detail && event.detail.current ? event.detail.current : 0
		},
		handlePreviewVideoError() {
			uni.showToast({
				title: '视频加载失败，请使用H.264编码MP4',
				icon: 'none'
			})
		},
		handleTextLineChange(event) {
			this.textLineCount = event.detail && event.detail.lineCount ? event.detail.lineCount : 1
		},
		handleTextInputFocus() {
			if (this.ensureFeatureAvailable(121)) return
			this.inputFocus = false
			uni.hideKeyboard && uni.hideKeyboard()
		},
			async sendTextMessage() {
				const message = buildTextOutgoingMessage(this.draftText, this.currentUserMeta)
				if (!message) return
				message.showReceiptToggle = false
				message.needReceipt = true
				const sent = await this.sendOutgoingByType(message)
				if (!sent) return
				this.appendMessage(message)
				this.draftText = ''
				this.textLineCount = 1
			},
		addImageFromUrl(url) {
			if (!url) return
			this.appendMessage(createImageMessage({
				...this.currentUserMeta,
				imageUrl: url
			}))
		}
	}
}
</script>

<style scoped lang="less">
	.message-page {
		height: 100vh;
		background: #f5f5f5;
		display: flex;
		flex-direction: column;
		overflow: hidden;

		.chat-nav {
			height: 110rpx;
			background: #f2f3f4;
			padding: 0 24rpx;
			display: flex;
			align-items: center;
			justify-content: space-between;
			flex-shrink: 0;
		}

		.chat-title-wrap {
			flex: 1;
			display: flex;
			justify-content: flex-start;
			align-items: center;
			min-width: 0;
		}

		.chat-title-picker {
			max-width: 460rpx;
			display: flex;
			align-items: center;
			gap: 8rpx;
		}

		.chat-title-picker.single {
			gap: 0;
		}

		.chat-name-arrow {
			font-size: 20rpx;
			color: #000000;
			line-height: 1;
		}

		.chat-title {
			font-size: 30rpx;
			font-weight: 400;
			color: #000000;
			max-width: 420rpx;
			white-space: nowrap;
			overflow: hidden;
			text-overflow: ellipsis;
		}

		.chat-nav-right {
			display: flex;
			align-items: center;
			gap: 16rpx;
		}

		.chat-call-btn {
			width: 78rpx;
			height: 78rpx;
			border-radius: 12rpx;
			background: #ffffff;
			border: 1rpx solid #e5e5e5;
			display: flex;
			align-items: center;
			justify-content: center;
		}

		.chat-logout-icon {
			width: 36rpx;
			height: 36rpx;
			display: block;
		}

		.message-list {
			flex: 1;
			padding: 20rpx;
			background: #fff;
			overflow-y: auto;
			box-sizing: border-box;
		}

		.history-loading-tip {
			padding: 12rpx 0 6rpx;
			text-align: center;
			font-size: 24rpx;
			color: #9ca3af;
		}

	.message-item {
		display: flex;
		align-items: flex-start;
		margin: 42rpx 0;
		gap: 20rpx;
	}

	/* 头像+时间区域 */
	.avatar-section {
		display: flex;
		flex-direction: column;
		align-items: center;
	}

	.avatar {
		width: 92rpx;
		height: 92rpx;
		border-radius: 50%;
		flex-shrink: 0;
	}

	.avatar.square-avatar {
		border-radius: 8rpx;
	}

	.avatar.doc-avatar {
		object-position: center 5px;
	}

	.msg-time {
		font-size: 32rpx;
		color: #666;
		white-space: nowrap;
	}

	/* 内容区域 */
	.content-section {
		flex: 1;
		display: flex;
		flex-direction: column;
		gap: 10rpx;
	}

	.user-info {
		font-size: 28rpx;
		color: #666;
	}

	.user-info-role {
		display: none;
	}

	.message-content {
		display: flex;
		align-items: flex-end;
		gap: 10rpx;
	}

	/* 文字消息容器 */
	.text-message-wrapper {
		display: flex;
		align-items: flex-end;
		gap: 10rpx;
	}

	/* 带回执按钮容器 */
	.receipt-toggle-wrapper {
		display: flex;
		align-items: center;
	}

	/* 消息气泡通用样式 */
		.text-message,
		.call-message,
		.voice-message,
		.file-message {
			background: #7d89b1;
			padding: 16rpx 20rpx;
			border-radius: 8rpx;
			color: #fff;
			font-size: 32rpx;
		}

		.image-message,
		.video-message {
			border-radius: 8rpx;
			overflow: hidden;
			border: 1rpx solid #dcdcdc;
			background: #ffffff;
		}

	/* 文字消息 */
	.text-message {
		max-width: 400rpx;
		word-wrap: break-word;
	}

	/* 通话记录 */
	.call-message {
		display: flex;
		align-items: center;
		gap: 10rpx;
	}

	.call-message image {
		width: 40rpx;
		height: 40rpx;
	}

	/* 语音消息 */
	.voice-message {
		position: relative;
		display: flex;
		align-items: center;
		gap: 10rpx;
		min-width: 100rpx;
		transition: background-image 0.12s linear;
	}

		.voice-message image {
			width: 40rpx;
			height: 40rpx;
		}

		.file-message {
			display: flex;
			align-items: center;
			gap: 12rpx;
			max-width: 420rpx;
		}

		.file-name {
			flex: 1;
			min-width: 0;
			font-size: 32rpx;
			white-space: nowrap;
			overflow: hidden;
			text-overflow: ellipsis;
		}

		.image-preview {
			width: 280rpx;
			height: 220rpx;
			display: block;
		}

		.video-message {
			position: relative;
			width: 280rpx;
			height: 220rpx;
		}

		.video-cover {
			width: 100%;
			height: 100%;
			display: block;
		}

		.video-play {
			position: absolute;
			left: 50%;
			top: 50%;
			transform: translate(-50%, -50%);
			width: 64rpx;
			height: 64rpx;
			border-radius: 50%;
			background: rgba(0, 0, 0, 0.45);
			display: flex;
			align-items: center;
			justify-content: center;
		}

	.recording-dot {
		width: 16rpx;
		height: 16rpx;
		background: #eb6100;
		border-radius: 50%;
		position: absolute;
		right: 10rpx;
		top: 10rpx;
	}

	/* 语音转文字区域 */
	.voice-text-section {
		display: flex;
		align-items: flex-end;
		gap: 20rpx;
	}

	.voice-text-content {
		flex: 1;
		background: #7d89b1;
		padding: 20rpx;
		border-radius: 8rpx;
		font-size: 32rpx;
		color: #fff;
		line-height: 1.5;
	}

	/* 回执按钮通用样式 */
	.inline-receipt-button,
	.receipt-button {
		background: #565d6d;
		color: #fff;
		padding: 10rpx 20rpx;
		border-radius: 8rpx;
		font-size: 28rpx;
		white-space: nowrap;
		cursor: pointer;
	}

	/* 右侧消息（发送） */
	.right {
		flex-direction: row-reverse;
	}

	.right .avatar {
		border-radius: 50%;
	}

	.right .avatar.square-avatar {
		border-radius: 8rpx;
	}

	.right .content-section {
		align-items: flex-end;
	}

	.right .user-info {
		text-align: right;
	}

	.user-info-placeholder {
		color: transparent;
	}

	.user-info-placeholder .user-info-role {
		display: inline;
	}


	/* 右侧消息气泡样式 */
		.right .text-message,
		.right .call-message,
		.right .voice-message,
		.right .file-message,
		.right .voice-text-content {
			background: #eeeeee;
			color: #333333;
			border: 1rpx solid #dcdcdc;
		}

	.footer {
		padding: 16rpx 24rpx;
		background: #eeeeee;
		display: flex;
		align-items: flex-end;
		gap: 16rpx;
		border-top: 4rpx solid transparent;
		box-sizing: border-box;
	}

	.footer.active {
		border-top-color: #dcdcdc;
	}

	.tool-btn {
		width: 78rpx;
		height: 78rpx;
		border-radius: 4rpx;
		background: #ffffff;
		border: 1rpx solid #e5e7eb;
		display: flex;
		align-items: center;
		justify-content: center;
		flex-shrink: 0;
		align-self: flex-end;
	}

	.center-area {
		flex: 1;
		min-width: 0;
		display: flex;
		align-items: flex-end;
	}

	.press-to-talk {
		width: 100%;
		height: 78rpx;
		border-radius: 4rpx;
		background: #ffffff;
		border: 1rpx solid #e5e7eb;
		display: flex;
		align-items: center;
		justify-content: center;
		gap: 12rpx;
	}

	.press-text {
		color: #6b7280;
		font-size: 30rpx;
		line-height: 1;
	}

	.text-input {
		width: 100%;
		min-height: 82rpx;
		max-height: 234rpx;
		border-radius: 4rpx;
		background: #ffffff;
		border: 1rpx solid #e5e7eb;
		padding: 18rpx 20rpx;
		font-size: 32rpx;
		line-height: 39rpx;
		color: #111827;
		overflow-y: auto;
		box-sizing: border-box;
	}

	.text-input.capped {
		height: 234rpx;
	}

	.bottom-panel {
		background: #eeeeee;
		border-top: 1rpx solid transparent;
		max-height: 0;
		opacity: 0;
		overflow: hidden;
		transform: translateY(24rpx);
		transition: max-height 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
	}

	.bottom-panel.open {
		border-top-color: #dcdcdc;
		max-height: 380rpx;
		opacity: 1;
		transform: translateY(0);
	}

	.more-panel {
		background: #eeeeee;
		padding: 30rpx 24rpx;
		box-sizing: border-box;
	}

	.emoji-panel {
		background: #eeeeee;
		padding: 20rpx 24rpx;
		max-height: 320rpx;
		overflow-y: auto;
	}

	.emoji-grid {
		display: grid;
		grid-template-columns: repeat(8, 1fr);
		gap: 12rpx;
	}

	.emoji-item {
		height: 52rpx;
		line-height: 52rpx;
		text-align: center;
		font-size: 36rpx;
		border-radius: 4rpx;
	}

		.more-grid {
			display: grid;
			grid-template-columns: repeat(4, minmax(0, 1fr));
			column-gap: 0;
			row-gap: 0;
		}

		.more-item {
			width: 100%;
			display: flex;
			flex-direction: column;
			align-items: center;
			gap: 16rpx;
		}

	.more-icon {
		width: 96rpx;
		height: 96rpx;
		border-radius: 4rpx;
		background: #ffffff;
		border: 1rpx solid #e5e7eb;
		display: flex;
		align-items: center;
		justify-content: center;
	}

		.more-text {
			font-size: 28rpx;
			color: #6b7280;
			white-space: nowrap;
		}

		.media-preview-mask {
			position: fixed;
			left: 0;
			top: 0;
			width: 100vw;
			height: 100vh;
			background: rgba(0, 0, 0, 0.78);
			display: flex;
			align-items: center;
			justify-content: center;
			z-index: 9999;
		}

		.media-preview-content {
			display: flex;
			align-items: center;
			justify-content: center;
			padding: 0;
			box-sizing: border-box;
		}

		.media-preview-content.video-mode {
			width: 100vw;
			height: 100vh;
			background: transparent;
			border-radius: 0;
		}

		.media-preview-content.image-mode {
			width: 100vw;
			height: 100vh;
			background: transparent;
			border-radius: 0;
		}

		.media-preview-video {
			width: 100vw;
			height: 100vh;
			object-fit: cover;
		}

		.media-preview-exit {
			position: fixed;
			top: 90rpx;
			right: 30rpx;
			height: 56rpx;
			line-height: 56rpx;
			padding: 0 18rpx;
			border-radius: 28rpx;
			font-size: 26rpx;
			color: #fff;
			background: rgba(0, 0, 0, 0.45);
			z-index: 10001;
		}

		.media-preview-swiper {
			width: 100vw;
			height: 100vh;
		}

		.media-preview-swiper-item {
			display: flex;
			align-items: center;
			justify-content: center;
		}

		.media-preview-image.fit-x {
			width: 100vw;
			height: auto;
		}

		.file-dialog-mask {
			position: fixed;
			left: 0;
			top: 0;
			width: 100vw;
			height: 100vh;
			background: rgba(255, 255, 255, 0.96);
			display: flex;
			align-items: center;
			justify-content: center;
			z-index: 10000;
		}

		.file-dialog {
			width: 100vw;
			height: 100vh;
			background: transparent;
			border-radius: 0;
			padding: 0 60rpx;
			box-sizing: border-box;
			display: flex;
			flex-direction: column;
			align-items: center;
			justify-content: center;
		}

		.file-dialog-header {
			display: flex;
			flex-direction: column;
			align-items: center;
			gap: 18rpx;
		}

		.file-dialog-name {
			max-width: 620rpx;
			font-size: 30rpx;
			color: #333;
			white-space: nowrap;
			overflow: hidden;
			text-overflow: ellipsis;
			text-align: center;
		}

		.file-dialog-actions {
			display: flex;
			justify-content: center;
			gap: 24rpx;
			padding-top: 40rpx;
		}

		.file-dialog-btn {
			min-width: 120rpx;
			height: 64rpx;
			line-height: 64rpx;
			text-align: center;
			background: #575d6d;
			color: #fff;
			font-size: 28rpx;
			border-radius: 8rpx;
		}

		.file-dialog-btn.ghost {
			background: #f1f2f4;
			color: #666;
		}

		.record-mask {
			position: fixed;
			left: 0;
			top: 0;
			width: 100vw;
			height: 100vh;
			background: rgba(0, 0, 0, 0.2);
			display: flex;
			align-items: center;
			justify-content: center;
			z-index: 12000;
			pointer-events: none;
		}

		.record-panel {
			width: 360rpx;
			padding: 30rpx 24rpx;
			border-radius: 16rpx;
			background: rgba(0, 0, 0, 0.72);
			display: flex;
			flex-direction: column;
			align-items: center;
			gap: 14rpx;
		}

		.record-title {
			font-size: 32rpx;
			color: #fff;
		}

		.record-time {
			font-size: 42rpx;
			font-weight: 600;
			color: #fff;
		}

		.record-tip {
			font-size: 26rpx;
			color: rgba(255, 255, 255, 0.9);
		}

		.record-tip.danger {
			color: #ffb2b2;
		}

		.service-confirm-body {
			width: 100%;
			flex: 1;
			display: flex;
			align-items: center;
			justify-content: center;
			box-sizing: border-box;
			padding: 24rpx 32rpx;
			font-size: 30rpx;
			color: #333333;
			text-align: center;
			line-height: 1.6;
			overflow: hidden;
		}

	}
</style>
