apple hace 17 horas
padre
commit
b9006c7809
Se han modificado 7 ficheros con 662 adiciones y 287 borrados
  1. 3 4
      api/device.js
  2. 10 0
      api/grfw.js
  3. 17 17
      components/SsBottom/index.vue
  4. 32 11
      components/SsConfirm/index.vue
  5. 144 148
      pages/main/index.vue
  6. 456 107
      pages/parent/message.vue
  7. BIN
      static/logo.png

+ 3 - 4
api/device.js

@@ -142,11 +142,10 @@ export const deviceApi = {
    *   ssMsg: '错误信息'
    * }
    */
-  grfw_chkGrfw(grfwxmm) {
-    const code = String(grfwxmm || '').trim()
+  grfw_chkGrfw() {
     return deviceRequest.post(
-      `/service?ssServ=grfw_chkGrfw&grfwxmm=${encodeURIComponent(code)}`,
-      { grfwxmm: code },
+      `/service?ssServ=grfw_chkGrfw`,
+      {},
       {
         loading: false,
         formData: true

+ 10 - 0
api/grfw.js

@@ -20,6 +20,16 @@ export const grfwApi = {
             }
         );
     },
+    grfw_chkGrfw: () => {
+        return request.post(
+            `/service?ssServ=grfw_chkGrfw`,
+            {},
+            {
+                loading: false,
+                formData: true
+            }
+        );
+    },
 
     /**
      * 初始化个人服务包购买 - 显示可选服务包

+ 17 - 17
components/SsBottom/index.vue

@@ -1,5 +1,5 @@
-<template>
-    <view class="bottom">
+<template>
+    <view class="bottom">
 		<view class="bottom-top-div" v-if="showShyj">
 			<up-table style="margin: 0 !important;width: 100% !important;height: 100rpx !important;">
 				<up-tr style="height: 100%;">
@@ -29,11 +29,11 @@ import SsInput from '@/components/SsInput/index.vue'
 import Td from '@/components/Td/index.vue'
 const reason = ref('')
 
-const props = defineProps({
-    // 是否显示审核意见
-    showShyj: {
-        type: Boolean,
-        default: false
+const props = defineProps({
+    // 是否显示审核意见
+    showShyj: {
+        type: Boolean,
+        default: false
     },
     // 审核意见标题
     shyjTitle: {
@@ -76,16 +76,16 @@ const handleShyjInput = (value) => {
 }
 </script>
 
-<style scoped lang="scss">
-.bottom {
-    position: fixed;
-    bottom: 0;
-    left: 0;
-    width: 100%;
-    z-index: 999;
-}
-.bottom-button-div{
-    display: flex;
+<style scoped lang="scss">
+.bottom{
+    width: 100%;
+    position: fixed;
+    bottom: 0;
+    left: 0;
+    z-index: 999;
+}
+.bottom-button-div{
+    display: flex;
     justify-content: space-between;
     align-items: center;
     background-color: #fff;

+ 32 - 11
components/SsConfirm/index.vue

@@ -14,8 +14,10 @@
       <view class="header-line" v-if="headerVisible"></view>
 
       <!-- 主要内容区域 - 通过slot传入 -->
-      <view class="confirm-body">
-        <slot></slot>
+      <view class="confirm-main">
+        <view class="confirm-body">
+          <slot></slot>
+        </view>
       </view>
 
       <!-- 底部按钮 -->
@@ -114,13 +116,13 @@ const contentStyle = computed(() => {
 /**
  * 处理遮罩点击
  */
-// const handleMaskClick = () => {
-//   emit('mask-click')
-//   if (props.maskClosable) {
-//     emit('update:visible', false)
-//     emit('close')
-//   }
-// }
+const handleMaskClick = () => {
+  emit('mask-click')
+  if (props.maskClosable) {
+    emit('update:visible', false)
+    emit('close')
+  }
+}
 
 /**
  * 处理底部按钮点击
@@ -155,6 +157,7 @@ const handleBottomClick = (button, index) => {
 
 // 弹窗内容
 .confirm-content {
+  --confirm-bottom-height: 100rpx;
   position: absolute;
   top: 50%;
   left: 50%;
@@ -189,8 +192,16 @@ const handleBottomClick = (button, index) => {
 }
 
 // 主要内容区域
-.confirm-body {
+.confirm-main {
   flex: 1;
+  min-height: 0;
+  box-sizing: border-box;
+  padding-bottom: var(--confirm-bottom-height);
+}
+
+.confirm-body {
+  width: 100%;
+  height: 100%;
   overflow: hidden;
   display: flex;
   flex-direction: column;
@@ -198,7 +209,17 @@ const handleBottomClick = (button, index) => {
 
 // 底部按钮
 .confirm-bottom {
-  position: relative;
+  position: absolute;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  height: var(--confirm-bottom-height);
   border-top: 1rpx solid #eee;
+  :deep(.bottom) {
+    position: static;
+    left: auto;
+    bottom: auto;
+    z-index: auto;
+  }
 }
 </style>

+ 144 - 148
pages/main/index.vue

@@ -6,15 +6,15 @@
             <swiper-item v-for="(page, index) in pages" :key="index">
                 <view class="page-container">
                     <BjdmStatisticsPage v-if="page.key === 'mp_njdmHomep' && page.activated"
-                        v-show="currentIndex === index" :ref="el => setPageRef(el, index)" />
+                        v-show="currentIndex === index" :ref="(el) => setPageRef(el, index)" />
                     <XcdmPage v-else-if="page.key === 'xcDm' && page.activated" v-show="currentIndex === index"
-                        :ref="el => setPageRef(el, index)" />
+                        :ref="(el) => setPageRef(el, index)" />
                     <BzrdmPage v-else-if="page.key === 'bzrDm' && page.activated" v-show="currentIndex === index"
-                        :ref="el => setPageRef(el, index)" />
+                        :ref="(el) => setPageRef(el, index)" />
                     <TodoPage v-else-if="page.key === 'todo' && page.activated" v-show="currentIndex === index"
-                        :ref="el => setPageRef(el, index)" />
+                        :ref="(el) => setPageRef(el, index)" />
                     <MyPage v-else-if="page.key === 'my' && page.activated" v-show="currentIndex === index"
-                        :ref="el => setPageRef(el, index)" />
+                        :ref="(el) => setPageRef(el, index)" />
                 </view>
             </swiper-item>
         </swiper>
@@ -22,132 +22,131 @@
 </template>
 
 <script setup>
-import { ref, nextTick } from 'vue'
-import { onShow, onHide, onLoad, onUnload } from '@dcloudio/uni-app'
-import Icon from '@/components/icon/index.vue'
+import { ref, nextTick } from "vue";
+import { onShow, onHide, onLoad, onUnload } from "@dcloudio/uni-app";
+import Icon from "@/components/icon/index.vue";
 
 // 引入各个页面组件
-import MyPage from '@/pages/my/index.vue'
-import TodoPage from '@/pages/todo/todo_list.vue'
-import BjdmStatisticsPage from '@/pages/statistics/bjdm_statistics.vue'
-import BzrdmPage from '@/pages/bjdm/bjdm_bzrDmHomep.vue'
-import XcdmPage from '@/pages/xcdm/index.vue'
+import MyPage from "@/pages/my/index.vue";
+import TodoPage from "@/pages/todo/todo_list.vue";
+import BjdmStatisticsPage from "@/pages/statistics/bjdm_statistics.vue";
+import BzrdmPage from "@/pages/bjdm/bjdm_bzrDmHomep.vue";
+import XcdmPage from "@/pages/xcdm/index.vue";
 
 // 当前选中的页面索引
-const currentIndex = ref(0)
+const currentIndex = ref(0);
 // 上一个页面索引
-const prevIndex = ref(0)
+const prevIndex = ref(0);
 
 // 页面组件引用数组
-const pageRefs = ref([])
+const pageRefs = ref([]);
 
 // 页面配置(根据登录态与 syList 动态构建)
-const pages = ref([])
+const pages = ref([]);
 
 /**
  * 设置页面组件引用
  */
 const setPageRef = (el, index) => {
     if (el) {
-        pageRefs.value[index] = el
-
+        pageRefs.value[index] = el;
     }
-}
+};
 
 /**
  * 触发子组件的生命周期
  */
 const triggerPageLifecycle = (pageIndex, lifecycle) => {
-    const pageRef = pageRefs.value[pageIndex]
+    const pageRef = pageRefs.value[pageIndex];
 
-    if (pageRef && typeof pageRef[lifecycle] === 'function') {
+    if (pageRef && typeof pageRef[lifecycle] === "function") {
         //  console.log(`[Main] 触发页面 ${pages.value[pageIndex]?.title} 的 ${lifecycle}`)
-        pageRef[lifecycle]()
+        pageRef[lifecycle]();
     } else {
         try {
-            console.warn('[Main] 生命周期调用失败: 未找到方法', {
+            console.warn("[Main] 生命周期调用失败: 未找到方法", {
                 pageIndex,
                 lifecycle,
                 hasRef: !!pageRef,
-                refKeys: pageRef ? Object.keys(pageRef) : []
-            })
+                refKeys: pageRef ? Object.keys(pageRef) : [],
+            });
         } catch (e) { }
     }
-}
+};
 
 // 在小程序环境中,组件渲染与 ref 赋值可能比 nextTick 更晚
 // 增加一个轻量重试机制,确保拿到子组件并调用其暴露的生命周期
 function ensureRefReady(pageIndex, lifecycle, tries = 0) {
-    const pageRef = pageRefs.value[pageIndex]
-    if (pageRef && typeof pageRef[lifecycle] === 'function') {
+    const pageRef = pageRefs.value[pageIndex];
+    if (pageRef && typeof pageRef[lifecycle] === "function") {
         //  console.log(`[Main] ensureRefReady 命中 -> 调用 ${pages.value[pageIndex]?.title}.${lifecycle}`)
-        pageRef[lifecycle]()
-        return
+        pageRef[lifecycle]();
+        return;
     }
     if (tries >= 8) {
-        console.warn('[Main] ensureRefReady 超过重试次数,放弃调用', {
+        console.warn("[Main] ensureRefReady 超过重试次数,放弃调用", {
             pageIndex,
             lifecycle,
             hasRef: !!pageRef,
-            refKeys: pageRef ? Object.keys(pageRef) : []
-        })
-        return
+            refKeys: pageRef ? Object.keys(pageRef) : [],
+        });
+        return;
     }
-    setTimeout(() => ensureRefReady(pageIndex, lifecycle, tries + 1), 30)
+    setTimeout(() => ensureRefReady(pageIndex, lifecycle, tries + 1), 30);
 }
 
 /**
  * swiper 切换事件
  */
 const onSwiperChange = (e) => {
-    const newIndex = e.detail.current
-    const oldIndex = currentIndex.value
+    const newIndex = e.detail.current;
+    const oldIndex = currentIndex.value;
 
     // 触发前一个页面的 onHide
     if (oldIndex !== newIndex) {
-        console.log(`[Main] 触发页面 ${oldIndex} 的 onHide`)
-        triggerPageLifecycle(oldIndex, 'onHide')
+        console.log(`[Main] 触发页面 ${oldIndex} 的 onHide`);
+        triggerPageLifecycle(oldIndex, "onHide");
     }
 
     // 更新当前页面索引
-    prevIndex.value = oldIndex
-    currentIndex.value = newIndex
+    prevIndex.value = oldIndex;
+    currentIndex.value = newIndex;
 
     // 懒加载激活
-    activateByIndex(currentIndex.value)
+    activateByIndex(currentIndex.value);
 
     // 触发新页面的 onShow(可能需要等待 ref 就绪)
     nextTick(() => {
-        console.log(`[Main] 触发页面 ${newIndex} 的 onShow`)
-        ensureRefReady(currentIndex.value, 'onShow')
-    })
+        console.log(`[Main] 触发页面 ${newIndex} 的 onShow`);
+        ensureRefReady(currentIndex.value, "onShow");
+    });
 
-    console.log('切换到页面:', pages.value[currentIndex.value].title)
-}
+    console.log("切换到页面:", pages.value[currentIndex.value].title);
+};
 
 /**
  * 点击指示器切换页面
  */
 function switchToPage(index) {
     if (currentIndex.value !== index) {
-        const oldIndex = currentIndex.value
+        const oldIndex = currentIndex.value;
 
         // 触发前一个页面的 onHide
         // console.log(`[Main] switchToPage 触发页面 ${oldIndex} 的 onHide`)
-        triggerPageLifecycle(oldIndex, 'onHide')
+        triggerPageLifecycle(oldIndex, "onHide");
 
         // 更新页面索引
-        prevIndex.value = oldIndex
-        currentIndex.value = index
+        prevIndex.value = oldIndex;
+        currentIndex.value = index;
 
         // 懒加载激活
-        activateByIndex(currentIndex.value)
+        activateByIndex(currentIndex.value);
 
         // 触发新页面的 onShow(可能需要等待 ref 就绪)
         nextTick(() => {
             // console.log(`[Main] switchToPage 触发页面 ${index} 的 onShow`)
-            ensureRefReady(currentIndex.value, 'onShow')
-        })
+            ensureRefReady(currentIndex.value, "onShow");
+        });
     }
 }
 
@@ -155,15 +154,15 @@ function switchToPage(index) {
  * 激活页面
  */
 function activateByIndex(pageIndex) {
-    const page = pages.value[pageIndex]
-    if (!page) return
+    const page = pages.value[pageIndex];
+    if (!page) return;
     if (!page.activated) {
         // 标记为已激活,触发首次挂载
-        page.activated = true
+        page.activated = true;
         // 首次激活后,等组件挂载完成再触发子组件 onLoad(自定义)
         nextTick(() => {
-            ensureRefReady(pageIndex, 'onLoad')
-        })
+            ensureRefReady(pageIndex, "onLoad");
+        });
     }
 }
 
@@ -172,12 +171,12 @@ function activateByIndex(pageIndex) {
  */
 function isLoggedIn() {
     try {
-        const u = uni.getStorageSync('userInfo')
-        if (!u) return false
-        const info = typeof u === 'string' ? JSON.parse(u) : u
-        return !!info?.yhsbToken
+        const u = uni.getStorageSync("userInfo");
+        if (!u) return false;
+        const info = typeof u === "string" ? JSON.parse(u) : u;
+        return !!info?.yhsbToken;
     } catch (e) {
-        return false
+        return false;
     }
 }
 
@@ -186,14 +185,14 @@ function isLoggedIn() {
  */
 function getSyList() {
     try {
-        const u = uni.getStorageSync('userInfo')
-        const info = typeof u === 'string' ? JSON.parse(u) : u
-        const raw = info?.syList || info?.sylist || '[]'
+        const u = uni.getStorageSync("userInfo");
+        const info = typeof u === "string" ? JSON.parse(u) : u;
+        const raw = info?.syList || info?.sylist || "[]";
         // return ['statistics','bzrDmHomep']
-        if (Array.isArray(raw)) return raw
-        return JSON.parse(raw)
+        if (Array.isArray(raw)) return raw;
+        return JSON.parse(raw);
     } catch (e) {
-        return []
+        return [];
     }
 }
 
@@ -203,145 +202,142 @@ function getSyList() {
  * - 已登录:按 syList 渲染可见首页
  */
 function buildPagesFromAuth() {
-    const list = []
-    const loggedIn = isLoggedIn()
-    const sy = loggedIn ? getSyList() : []
+    const list = [];
+    const loggedIn = isLoggedIn();
+    const sy = loggedIn ? getSyList() : [];
 
     // 能力 → 页面配置映射(按需扩展)
     const capabilityMap = {
         mp_xcdmHomep: {
-            key: 'xcDm',
-            title: '校车点名',
-            icon: 'icon-dianming',
-            path: 'pages/xcdm/index',
+            key: "xcDm",
+            title: "校车点名",
+            icon: "icon-dianming",
+            path: "pages/xcdm/index",
             component: XcdmPage,
         },
         mp_njdmHomep: {
-            key: 'mp_njdmHomep',
-            title: '点名统计',
-            icon: 'icon-tongji',
-            path: 'pages/statistics/bjdm_statistics',
+            key: "mp_njdmHomep",
+            title: "点名统计",
+            icon: "icon-tongji",
+            path: "pages/statistics/bjdm_statistics",
             component: BjdmStatisticsPage,
         },
         mp_bzrdmHomep: {
-            key: 'bzrDm',
-            title: '班主任点名',
-            icon: 'icon-dianming',
-            path: 'pages/bjdm/bjdm_bzrDm',
+            key: "bzrDm",
+            title: "班主任点名",
+            icon: "icon-dianming",
+            path: "pages/bjdm/bjdm_bzrDm",
             component: BzrdmPage,
         },
         todo: {
-            key: 'todo',
-            title: '待办',
-            icon: 'icon-daiban',
-            path: 'pages/todo/todo_list',
+            key: "todo",
+            title: "待办",
+            icon: "icon-daiban",
+            path: "pages/todo/todo_list",
             component: TodoPage,
         },
-    }
+    };
 
     if (loggedIn) {
-        const added = new Set()
+        const added = new Set();
         // 仅当 syList 含 mp_xcdmHomep 时才展示“校车点名”
-        if (sy.includes('mp_xcdmHomep')) {
-            list.push({ ...capabilityMap.mp_xcdmHomep, activated: false })
-            added.add(capabilityMap.mp_xcdmHomep.key)
+        if (sy.includes("mp_xcdmHomep")) {
+            list.push({ ...capabilityMap.mp_xcdmHomep, activated: false });
+            added.add(capabilityMap.mp_xcdmHomep.key);
         }
-        sy.forEach(cap => {
-            const conf = capabilityMap[cap]
+        sy.forEach((cap) => {
+            const conf = capabilityMap[cap];
             if (conf && !added.has(conf.key)) {
-                list.push({ ...conf, activated: false })
-                added.add(conf.key)
+                list.push({ ...conf, activated: false });
+                added.add(conf.key);
             }
-        })
+        });
     }
 
     // 末尾永远追加“我的”
     list.push({
-        key: 'my',
-        title: '我的',
-        icon: 'icon-wode',
-        path: 'pages/my/index',
+        key: "my",
+        title: "我的",
+        icon: "icon-wode",
+        path: "pages/my/index",
         component: MyPage,
         activated: false,
-    })
+    });
 
-    pages.value = list
-    console.log('✅ 获取权限列表成功:', list)
+    pages.value = list;
+    console.log("✅ 获取权限列表成功:", list);
 }
 
 // 主容器的生命周期
 onLoad((options) => {
-    // 测试写死
-    // options.sn = 'ssDevId_a'
-    // options.cardNo = 'E00401532101245F'
-    if(options.cardNo == 'E004015316BE6182'){
-        options.cardNo = 'E004015316BE61821'
-    }
+    // options.sn = "ssDevId_a";
+    // options.cardNo = "E004015327BD68C5";
     // if (true) {
-    if (typeof wmpf !== 'undefined') { 
-        console.log('WMPF环境')
-        const sn = options.sn || ''
-        const cardNo = options.cardNo || ''
+
+    if (typeof wmpf !== "undefined") {
+        console.log("WMPF环境");
+        const sn = options.sn || "";
+        const cardNo = options.cardNo || "";
+
         if (sn && cardNo) {
             uni.reLaunch({
-                url: `/pages/parent/message?role=device&sn=${encodeURIComponent(sn)}&cardNo=${encodeURIComponent(cardNo)}`
-            })
-            return
+                url: `/pages/parent/message?role=device&sn=${sn}&cardNo=${cardNo}`,
+            });
+            return;
         }
+
         if (sn && !cardNo) {
             uni.reLaunch({
-                url: `/pages/device/notice?sn=${sn}`
-            })
-            return
+                url: `/pages/device/notice?sn=${sn}`,
+            });
+            return;
         }
     } else {
-        console.log('非WMPF环境')
-        //  console.log('主容器页面加载', options)
+        console.log("非WMPF环境");
         // 先按登录态构建一级页
-        buildPagesFromAuth()
+        buildPagesFromAuth();
         // 临时:主首页写死默认打开“校车点名”
-        const xcdmIndex = pages.value.findIndex(p => p.key === 'xcDm')
-        currentIndex.value = xcdmIndex >= 0 ? xcdmIndex : 0
+        const xcdmIndex = pages.value.findIndex((p) => p.key === "xcDm");
+        currentIndex.value = xcdmIndex >= 0 ? xcdmIndex : 0;
         // 激活当前页(首次时会触发 onLoad)
-        activateByIndex(currentIndex.value)
+        activateByIndex(currentIndex.value);
 
         // 监听登录事件,登录后重建一级页
-        uni.$on('login', () => {
-            const currentKey = pages.value[currentIndex.value]?.key
-            buildPagesFromAuth()
+        uni.$on("login", () => {
+            const currentKey = pages.value[currentIndex.value]?.key;
+            buildPagesFromAuth();
             // 临时:登录后默认打开“校车点名”
-            const idx = pages.value.findIndex(p => p.key === 'xcDm')
-            currentIndex.value = idx >= 0 ? idx : 0
-            activateByIndex(currentIndex.value)
+            const idx = pages.value.findIndex((p) => p.key === "xcDm");
+            currentIndex.value = idx >= 0 ? idx : 0;
+            activateByIndex(currentIndex.value);
             nextTick(() => {
-                ensureRefReady(currentIndex.value, 'onShow')
-            })
-        })
+                ensureRefReady(currentIndex.value, "onShow");
+            });
+        });
     }
-})
+});
 
 onShow(() => {
     //  console.log('主容器页面显示')
     // 触发当前页面的 onShow(可能需要等待 ref 就绪)
     nextTick(() => {
-        ensureRefReady(currentIndex.value, 'onShow')
-    })
-})
+        ensureRefReady(currentIndex.value, "onShow");
+    });
+});
 
 onHide(() => {
     //  console.log('主容器页面隐藏')
     // 触发当前页面的 onHide
-    triggerPageLifecycle(currentIndex.value, 'onHide')
-})
+    triggerPageLifecycle(currentIndex.value, "onHide");
+});
 
 onUnload(() => {
     //  console.log('主容器页面卸载')
     // 触发所有页面的 onUnload
     pages.value.forEach((_, index) => {
-        triggerPageLifecycle(index, 'onUnload')
-    })
-
-})
+        triggerPageLifecycle(index, "onUnload");
+    });
+});
 </script>
 
 <style lang="scss" scoped>

+ 456 - 107
pages/parent/message.vue

@@ -32,8 +32,19 @@
 		</view>
 
 		<!-- 消息列表 -->
-		<scroll-view class="message-list" scroll-y :scroll-top="scrollTop" :scroll-into-view="scrollIntoView"
-			@touchstart="handleListTouchStart" @touchmove="handleListTouchMove" @touchend="handleListTouchEnd">
+			<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">
 				<!-- 头像+时间区域 -->
@@ -42,6 +53,7 @@
 						class="avatar"
 						:class="getAvatarClass(message)"
 						:src="getAvatarSrc(message)"
+						@error="handleAvatarLoadError(message, index)"
 						mode="aspectFill"
 					></image>
 					<text class="msg-time">{{ message.time || '--:--' }}</text>
@@ -155,6 +167,7 @@
 					<view
 						v-if="inputMode === 'voice'"
 						class="press-to-talk"
+						@touchstart="handlePressToTalkTouchStart"
 						@longpress="handlePressToTalkStart"
 						@touchmove.stop.prevent="handlePressToTalkMove"
 						@touchend.stop.prevent="handlePressToTalkEnd"
@@ -174,6 +187,7 @@
 						@confirm="sendTextMessage"
 						maxlength="-1"
 						@linechange="handleTextLineChange"
+						@focus="handleTextInputFocus"
 						@blur="inputFocus = false"
 					/>
 				</view>
@@ -191,12 +205,12 @@
 		<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-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> -->
 					<view class="more-item" @click="handleMoreAction('image')">
 						<view class="more-icon">
 							<Icon lib="base" name="icon-img" size="36" :color="iconColor" />
@@ -286,12 +300,25 @@
 				@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 {
@@ -314,6 +341,7 @@ 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 {
@@ -355,10 +383,11 @@ const miniprogramState = (() => {
 				default: ''
 			}
 		},
-			components: {
-				Icon,
-				customModal
-			},
+				components: {
+					Icon,
+					customModal,
+					SsConfirm
+				},
 			computed: {},
 			watch: {
 				role() {
@@ -379,8 +408,7 @@ const miniprogramState = (() => {
 			},
 			data() {
 		return {
-			scrollTop: 0,
-			scrollIntoView: '',
+				scrollIntoView: '',
 			inputMode: 'voice', // voice | text
 			inputFocus: false,
 			draftText: '',
@@ -405,6 +433,14 @@ const miniprogramState = (() => {
 				historyLoading: false,
 				historyWaiter: null,
 				historyWaiterTimer: null,
+				historyMode: 'initial',
+				historyBatchCount: 0,
+				historyPageNo: 0,
+				historyPendingMessages: [],
+				loadingMoreHistory: false,
+				hasMoreHistory: true,
+				historyCursorMsgId: '',
+				historyTopLoadArmed: true,
 				parentInfo: {
 					xm: '',
 					openid: ''
@@ -425,11 +461,14 @@ const miniprogramState = (() => {
 			isRecording: false,
 			isRecordCancel: false,
 			recordSeconds: 0,
-			recordStartY: 0,
-			recordTickTimer: null,
-			recordGuardTimer: null,
-				audioPlayer: null,
-				playingVoiceIndex: -1,
+				recordStartY: 0,
+				recordTickTimer: null,
+				recordGuardTimer: null,
+				recordPermissionPromise: null,
+				isPressingToTalk: false,
+				selfAvatarLoadFailed: false,
+					audioPlayer: null,
+					playingVoiceIndex: -1,
 				sessionInitialized: false,
 				sessionRole: 'parent',
 				sessionContactId: '',
@@ -456,6 +495,9 @@ const miniprogramState = (() => {
 				modalShowCancel: false,
 				modalConfirmText: '确定',
 				modalResolve: null,
+				serviceConfirmVisible: false,
+				serviceConfirmContent: '',
+				serviceConfirmButtons: [{ text: '关闭' }],
 				loadOptions: {},
 				// 消息列表数据
 				messages: []
@@ -491,10 +533,9 @@ const miniprogramState = (() => {
 				if (this.sessionRole === 'device') {
 					await (this.deviceInitPromise || Promise.resolve(true))
 					if (!this.deviceSessionReady) return
-					await this.checkServiceStatus(115)
-					await this.checkServiceStatus(111)
 					this.resetInactivityTimer()
 				}
+				await this.checkServiceStatus()
 				await this.bootstrapMessageFlow()
 			},
 			onUnload() {
@@ -797,30 +838,32 @@ const miniprogramState = (() => {
 				},
 				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(userInfo.zjzwj || userInfo.yszwj || ''),
+							url: this.toDisplayImageUrl(studentAvatar || parentAvatar || ''),
 							type: '51'
 						}
 					}
 					if (preferType === '1') {
 						return {
-							url: this.toDisplayImageUrl(userInfo.yszwj || userInfo.zjzwj || ''),
+							url: this.toDisplayImageUrl(parentAvatar || studentAvatar || ''),
 							type: '1'
 						}
 					}
 					if (this.sessionRole === 'device') {
-						if (userInfo.zjzwj) {
+						if (studentAvatar) {
 							return {
-								url: this.toDisplayImageUrl(userInfo.zjzwj),
+								url: this.toDisplayImageUrl(studentAvatar),
 								type: '51'
 							}
 						}
-						if (userInfo.yszwj) {
+						if (parentAvatar) {
 							return {
-								url: this.toDisplayImageUrl(userInfo.yszwj),
+								url: this.toDisplayImageUrl(parentAvatar),
 								type: '1'
 							}
 						}
@@ -829,15 +872,15 @@ const miniprogramState = (() => {
 							type: ''
 						}
 					}
-					if (userInfo.yszwj) {
+					if (parentAvatar) {
 						return {
-							url: this.toDisplayImageUrl(userInfo.yszwj),
+							url: this.toDisplayImageUrl(parentAvatar),
 							type: '1'
 						}
 					}
-					if (userInfo.zjzwj) {
+					if (studentAvatar) {
 						return {
-							url: this.toDisplayImageUrl(userInfo.zjzwj),
+							url: this.toDisplayImageUrl(studentAvatar),
 							type: '51'
 						}
 					}
@@ -848,9 +891,22 @@ const miniprogramState = (() => {
 				},
 				getAvatarSrc(message = {}) {
 					if (!message || message.direction === 'right') {
+						if (this.selfAvatarLoadFailed) return '/static/logo.png'
 						return this.getSelfAvatarMeta().url
 					}
-					return message.avatarUrl || '/static/logo.png'
+					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(
@@ -876,22 +932,24 @@ const miniprogramState = (() => {
 					if (this.sessionRole === 'device') {
 						return {
 							username: member.xm || member.username || '家长',
-							avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
+							avatar: this.toDisplayImageUrl(member.yszwj),
 							openid: member.wbid2 || member.wbid || ''
 						}
 					}
 					return {
 						username: member.xm || member.username || '学生',
-						avatar: member.yszwj ? this.toDisplayImageUrl(member.yszwj) : '/static/logo.png',
+						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')
 				},
@@ -1047,23 +1105,78 @@ const miniprogramState = (() => {
 						this.handleDeviceLogout()
 					}, this.inactivityTimeout)
 				},
-				async checkServiceStatus(grfwxmm) {
-					if (this.sessionRole !== 'device') return
-					const code = Number(grfwxmm)
-					if (code !== 111 && code !== 115) {
-						console.warn('checkServiceStatus skip: invalid grfwxmm', grfwxmm)
-						return
-					}
+				async checkServiceStatus() {
 					try {
-						const result = await deviceApi.grfw_chkGrfw(code)
+						const result = this.sessionRole === 'device'
+							? await deviceApi.grfw_chkGrfw()
+							: await grfwApi.grfw_chkGrfw()
 						const data = result?.data || {}
-						if (data.ssCode === 0 && data.ssData) {
-							this.serviceStatus = data.ssData
+						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 {
@@ -1078,7 +1191,7 @@ const miniprogramState = (() => {
 							ll: 0,
 							ms: `通话${duration}秒`
 						})
-						await this.checkServiceStatus(grfwxmm)
+						await this.checkServiceStatus()
 					} catch (error) {
 						console.error('记录通话失败:', error)
 					}
@@ -1234,6 +1347,7 @@ const miniprogramState = (() => {
 				},
 				async finishHistoryLoading() {
 					this.historyLoading = false
+					this.loadingMoreHistory = false
 					if (this.historyWaiterTimer) {
 						clearTimeout(this.historyWaiterTimer)
 						this.historyWaiterTimer = null
@@ -1246,7 +1360,7 @@ const miniprogramState = (() => {
 						await websocketService.disconnect()
 					}
 				},
-				async loadHistoryMessages({ reset = true } = {}) {
+				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)
@@ -1261,22 +1375,38 @@ const miniprogramState = (() => {
 						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')
-						await websocketService.send({
-							cmd: 161,
+						const payload = {
+							cmd,
 							sendRyid: this.wsIdentity.sendRyid,
-							recRyid: this.wsIdentity.recRyid
-						})
+							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()
@@ -1349,8 +1479,8 @@ const miniprogramState = (() => {
 					const un5 = websocketService.on('cmd:165', (payload) => {
 						this.handleWsHistoryMessage(payload)
 					})
-					const un6 = websocketService.on('cmd:11', () => {
-						this.handleWsHistoryDone()
+					const un6 = websocketService.on('cmd:11', (payload) => {
+						this.handleWsHistoryDone(payload)
 					})
 					const un7 = websocketService.on('cmd:151', (payload) => {
 						this.handleWsReceipt(payload)
@@ -1392,10 +1522,18 @@ const miniprogramState = (() => {
 						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) {
-					if (!path) return '/static/logo.png'
-					if (/^https?:\/\//.test(path)) return path
-					return getImageUrl(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 ''
@@ -1424,8 +1562,8 @@ const miniprogramState = (() => {
 					}
 					return cont
 				},
-				appendPayloadMessage(payload = {}, { fromHistory = false } = {}) {
-					if (!this.shouldHandleIncomingPayload(payload)) return
+				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 || '')
@@ -1441,16 +1579,43 @@ const miniprogramState = (() => {
 					const msgId = payload.xxid || payload.msgId || ''
 					if (fromHistory && msgId) {
 						const exists = this.messages.some((item) => String(item.msgId || '') === String(msgId))
-						if (exists) return
+						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))) {
-							this.appendMessage({
+							return pushMessage({
 								type: 'file',
 								direction,
 								department: messageMeta.department,
@@ -1465,9 +1630,8 @@ const miniprogramState = (() => {
 								receiptStatus,
 								msgId
 							})
-							return
 						}
-						this.appendMessage(createTextMessage({
+						return pushMessage(createTextMessage({
 							direction,
 							department: messageMeta.department,
 							name: messageMeta.name,
@@ -1480,11 +1644,10 @@ const miniprogramState = (() => {
 							time: displayTime,
 							msgId
 						}))
-						return
 					}
 
 					if (typeCode === '122') {
-						this.appendMessage(createImageMessage({
+						return pushMessage(createImageMessage({
 							direction,
 							department: messageMeta.department,
 							name: messageMeta.name,
@@ -1497,11 +1660,10 @@ const miniprogramState = (() => {
 							receiptStatus,
 							msgId
 						}))
-						return
 					}
 
 					if (typeCode === '123') {
-						this.appendMessage({
+						return pushMessage({
 							type: 'voice',
 							direction,
 							department: messageMeta.department,
@@ -1518,11 +1680,10 @@ const miniprogramState = (() => {
 							time: displayTime,
 							msgId
 						})
-						return
 					}
 
 					if (typeCode === '124') {
-						this.appendMessage({
+						return pushMessage({
 							type: 'video',
 							direction,
 							department: messageMeta.department,
@@ -1539,15 +1700,46 @@ const miniprogramState = (() => {
 							msgId
 						})
 					}
+					return false
 				},
 				handleWsIncomingMessage(payload = {}) {
 					this.appendPayloadMessage(payload, { fromHistory: false })
 				},
 				handleWsHistoryMessage(payload = {}) {
-					this.appendPayloadMessage(payload, { fromHistory: true })
+					const inserted = this.appendPayloadMessage(payload, {
+						fromHistory: true,
+						prepend: this.historyMode === 'older',
+						collectOnly: this.historyMode === 'older'
+					})
+					if (inserted) {
+						this.historyBatchCount += 1
+					}
 				},
-				handleWsHistoryDone() {
+				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 = {}) {
@@ -1556,6 +1748,7 @@ const miniprogramState = (() => {
 					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 = {}) {
@@ -1610,6 +1803,16 @@ const miniprogramState = (() => {
 				},
 				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) {
@@ -1823,8 +2026,85 @@ const miniprogramState = (() => {
 				this.isRecording = false
 				this.recordSeconds = 0
 			},
-			handlePressToTalkStart(event) {
+			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()
 				}
@@ -1871,10 +2151,12 @@ const miniprogramState = (() => {
 				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()
@@ -1942,6 +2224,7 @@ const miniprogramState = (() => {
 		},
 		toggleInputMode() {
 			if (this.inputMode === 'voice') {
+				if (!this.ensureFeatureAvailable(121)) return
 				this.inputMode = 'text'
 				this.showMorePanel = false
 				this.showEmojiPanel = false
@@ -2001,31 +2284,69 @@ const miniprogramState = (() => {
 				this.listTouchMoved = true
 			}
 		},
-		handleListTouchEnd() {
-			this.resetInactivityTimer()
-			if (!this.listTouchMoved) {
-				this.handlePageClick()
-			}
-		},
-		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)
+			handleListTouchEnd() {
+				this.resetInactivityTimer()
+				if (!this.listTouchMoved) {
+					this.handlePageClick()
 				}
-			})
-			this.$nextTick(() => {
-				this.scrollToBottom()
-			})
-		},
-		appendMessage(newMessage) {
-			if (!newMessage) return
-			this.appendMessages([newMessage])
-		},
+			},
+			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
@@ -2191,6 +2512,11 @@ const miniprogramState = (() => {
 		},
 		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)
@@ -2290,13 +2616,20 @@ const miniprogramState = (() => {
 			display: block;
 		}
 
-	.message-list {
-		flex: 1;
-		padding: 20rpx;
-		background: #fff;
-		overflow-y: auto;
-		box-sizing: border-box;
-	}
+		.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;
@@ -2665,19 +2998,20 @@ const miniprogramState = (() => {
 		border-radius: 4rpx;
 	}
 
-	.more-grid {
-		display: flex;
-		align-items: flex-start;
-		justify-content: space-between;
-	}
+		.more-grid {
+			display: grid;
+			grid-template-columns: repeat(4, minmax(0, 1fr));
+			column-gap: 0;
+			row-gap: 0;
+		}
 
-	.more-item {
-		width: 25%;
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		gap: 16rpx;
-	}
+		.more-item {
+			width: 100%;
+			display: flex;
+			flex-direction: column;
+			align-items: center;
+			gap: 16rpx;
+		}
 
 	.more-icon {
 		width: 96rpx;
@@ -2878,5 +3212,20 @@ const miniprogramState = (() => {
 			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>

BIN
static/logo.png