apple 6 дней назад
Родитель
Сommit
a1d5f235b2
36 измененных файлов с 4040 добавлено и 1564 удалено
  1. 60 43
      components/icon/index.vue
  2. 18 0
      constants/emoji.js
  3. 1 1
      pages.json
  4. 67 0
      pages/main/index.vue
  5. 3 3
      pages/my/index.vue
  6. 1439 332
      pages/parent/message.vue
  7. 654 483
      pages/payment/recharge.vue
  8. BIN
      static/.DS_Store
  9. 264 0
      static/iconfont/icon-base/iconfont.css
  10. BIN
      static/iconfont/icon-base/iconfont.ttf
  11. BIN
      static/iconfont/icon-base/iconfont.woff
  12. BIN
      static/iconfont/icon-base/iconfont.woff2
  13. 212 0
      static/iconfont/icon-biz/iconfont.css
  14. BIN
      static/iconfont/icon-biz/iconfont.ttf
  15. BIN
      static/iconfont/icon-biz/iconfont.woff
  16. BIN
      static/iconfont/icon-biz/iconfont.woff2
  17. 1 1
      unpackage/dist/dev/.sourcemap/mp-weixin/common/assets.js.map
  18. 0 0
      unpackage/dist/dev/.sourcemap/mp-weixin/common/vendor.js.map
  19. 0 1
      unpackage/dist/dev/.sourcemap/mp-weixin/components/icon/index.js.map
  20. 0 0
      unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-input/u-input.js.map
  21. 0 0
      unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-overlay/u-overlay.js.map
  22. 0 0
      unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-picker/u-picker.js.map
  23. 0 0
      unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-popup/u-popup.js.map
  24. 0 0
      unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-status-bar/u-status-bar.js.map
  25. 0 0
      unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-toolbar/u-toolbar.js.map
  26. 2 8
      unpackage/dist/dev/mp-weixin/common/assets.js
  27. 533 533
      unpackage/dist/dev/mp-weixin/common/vendor.js
  28. 19 5
      unpackage/dist/dev/mp-weixin/components/icon/index.js
  29. 1 1
      unpackage/dist/dev/mp-weixin/components/icon/index.wxml
  30. 4 5
      unpackage/dist/dev/mp-weixin/components/icon/index.wxss
  31. 1 1
      unpackage/dist/dev/mp-weixin/pages/my/index.wxml
  32. 4 1
      unpackage/dist/dev/mp-weixin/pages/parent/message.json
  33. 0 0
      unpackage/dist/dev/mp-weixin/pages/parent/message.wxml
  34. 452 146
      unpackage/dist/dev/mp-weixin/pages/parent/message.wxss
  35. 183 0
      utils/parent-message-factory.js
  36. 122 0
      utils/parent-message-send.js

+ 60 - 43
components/icon/index.vue

@@ -1,30 +1,36 @@
-<template>
-  <text 
-    :class="['iconfont', iconClass]" 
-    :style="{ color: color, fontSize: size + 'rpx',verticalAlign: 'middle'}"
-    @click="handleClick"
-  ></text>
-</template>
-
-<script setup>
-/**
- * 图标组件
- * 基于iconfont图标库
- */
-import { computed } from 'vue';
-
-// 组件属性定义
-const props = defineProps({
-  // 图标名称,对应iconfont中的类名,如'icon-home'
-  name: {
-    type: String,
-    required: true
-  },
-  // 图标颜色
-  color: {
-    type: String,
-    default: '#000'
-  },
+<template>
+  <text 
+	    :class="[fontClass, iconClass]" 
+	    :style="{ color: color, fontSize: size + 'rpx',verticalAlign: 'middle'}"
+	    @click="handleClick"
+	  ></text>
+</template>
+
+<script setup>
+/**
+ * 图标组件
+ * 基于iconfont图标库
+ */
+	import { computed } from 'vue';
+
+	// 组件属性定义
+	const props = defineProps({
+	  // 图标名称,对应iconfont中的类名,如'icon-home'
+	  name: {
+	    type: String,
+	    required: true
+	  },
+	  // 图标库:legacy(旧库)/base(基础库)/biz(业务库)
+	  // 不传默认使用旧库,兼容现有代码
+	  lib: {
+	    type: String,
+	    default: 'legacy'
+	  },
+	  // 图标颜色
+	  color: {
+	    type: String,
+	    default: '#000'
+	  },
   // 图标大小,单位rpx
   size: {
     type: [Number, String],
@@ -32,24 +38,35 @@ const props = defineProps({
   }
 });
 
-// 事件
-const emit = defineEmits(['click']);
-
-// 计算完整的图标类名
-const iconClass = computed(() => {
-  return props.name;
-});
+	// 事件
+	const emit = defineEmits(['click']);
+
+	// 选择字体库class
+	const fontClass = computed(() => {
+	  if (props.lib === 'base') return 'icon-base'
+	  if (props.lib === 'biz') return 'icon-biz'
+	  return 'iconfont'
+	})
+
+	// 计算完整的图标类名
+	const iconClass = computed(() => {
+	  return props.name;
+	});
 
 // 点击事件处理
 const handleClick = (event) => {
   emit('click', event);
 };
-</script>
-
-<style>
-/* 引入iconfont样式 */
-@import '../../static/iconfont/iconfont.css';
-.iconfont{
-  line-height: 1;
-}
-</style> 
+</script>
+
+<style>
+/* 引入iconfont样式 */
+@import '../../static/iconfont/iconfont.css';
+@import '../../static/iconfont/icon-base/iconfont.css';
+@import '../../static/iconfont/icon-biz/iconfont.css';
+.iconfont,
+.icon-base,
+.icon-biz{
+	  line-height: 1;
+	}
+</style> 

+ 18 - 0
constants/emoji.js

@@ -0,0 +1,18 @@
+export const EMOJI_LIST = [
+  '😀', '😄', '😁', '😂', '🤣', '😊', '😍', '😘',
+  '😭', '😤', '😴', '👍', '👏', '🎉', '❤️', '🙏',
+  '😎', '🤔', '😅', '😇', '🥳', '🤝', '💪', '✌️',
+  '😆', '😉', '😋', '😜', '🤪', '🤗', '🤭', '🤫',
+  '🤐', '😏', '🙄', '😬', '😌', '🤤', '😪', '🤒',
+  '🤕', '🤢', '🤮', '🥺', '😡', '🤯', '😳', '🥲',
+  '🙌', '👌', '👊', '✊', '👋', '🤟', '🤞', '🫶',
+  '💯', '🔥', '✨', '⭐', '🌈', '☀️', '🌙', '⛅',
+  '🎵', '🎶', '📌', '📎', '✅', '❗', '❓', '💡',
+  '🍎', '🍉', '🍔', '☕', '🏃', '⚽', '🎁', '🎂',
+  '🍀', '🌸', '🌹', '🌺', '🌻', '🌼', '🌷', '🪴',
+  '🐶', '🐱', '🐼', '🐨', '🐰', '🦊', '🐯', '🦁',
+  '🚀', '✈️', '🚗', '🚌', '🚲', '🏠', '🏫', '📚',
+  '⌚', '📱', '💻', '🖨️', '🎧', '📷', '🎮', '🧩',
+  '🍓', '🍒', '🍋', '🍇', '🍕', '🍟', '🍜', '🍰',
+  '⚡', '🌟', '🧡', '💛', '💚', '💙', '💜', '🖤'
+]

+ 1 - 1
pages.json

@@ -93,7 +93,7 @@
 		{
 			"path": "pages/payment/recharge",
 			"style": {
-				"navigationBarTitleText": "充值"
+				"navigationBarTitleText": "订阅服务"
 			}
 		},
 		{

+ 67 - 0
pages/main/index.vue

@@ -44,6 +44,67 @@ const pageRefs = ref([])
 // 页面配置(根据登录态与 syList 动态构建)
 const pages = ref([])
 
+// WebSocket 连接地址
+const SOCKET_URL = 'wss://m.hfdcschool.com:9988/'
+// WebSocket 连接实例
+let socketTask = null
+
+/**
+ * 初始化 WebSocket 连接
+ */
+function initWebSocket() {
+    if (socketTask) return
+
+    socketTask = uni.connectSocket({
+        url: SOCKET_URL,
+        fail: (err) => {
+            console.error('[Main] WebSocket connectSocket 调用失败:', err)
+        },
+        complete: () => { }
+    })
+
+    socketTask.onOpen(() => {
+        console.log('[Main] WebSocket 连接成功:', SOCKET_URL)
+
+        // 连接成功后立刻发送 ping
+        socketTask.send({
+            data: 'ping',
+            success: () => {
+                console.log('[Main] WebSocket ping 发送成功')
+            },
+            fail: (err) => {
+                console.error('[Main] WebSocket ping 发送失败:', err)
+            }
+        })
+    })
+
+    socketTask.onError((err) => {
+        console.error('[Main] WebSocket 连接失败:', err)
+    })
+
+    socketTask.onMessage((res) => {
+        console.log('[Main] WebSocket 收到消息:', res?.data)
+    })
+
+    socketTask.onClose((res) => {
+        console.log('[Main] WebSocket 连接关闭:', res)
+        socketTask = null
+    })
+}
+
+/**
+ * 关闭 WebSocket 连接
+ */
+function closeWebSocket() {
+    if (!socketTask) return
+
+    socketTask.close({
+        complete: () => {
+            socketTask = null
+        }
+    })
+}
+
 /**
  * 设置页面组件引用
  */
@@ -271,6 +332,9 @@ function buildPagesFromAuth() {
 
 // 主容器的生命周期
 onLoad((options) => {
+    // 页面进入时建立 WebSocket 连接
+    initWebSocket()
+
     // 测试写死
     // options.sn = 'A100006B6256E6'
     // options.cardNo = 'E00401532101245F'
@@ -331,6 +395,9 @@ onUnload(() => {
     pages.value.forEach((_, index) => {
         triggerPageLifecycle(index, 'onUnload')
     })
+
+    // 页面卸载时关闭 WebSocket 连接
+    closeWebSocket()
 })
 </script>
 

+ 3 - 3
pages/my/index.vue

@@ -29,7 +29,7 @@
 					<view class="function-icon recharge-icon">
 						<Icon name="icon-qianbao" size="60" color="#07c160" />
 					</view>
-					<text class="function-name">充值</text>
+					<text class="function-name">订阅</text>
 				</view>
 
 				<!-- 校车位置(家长) -->
@@ -41,12 +41,12 @@
 				</view> -->
 
 				<!-- 呼叫留言 -->
-				<!-- <view class="function-item" @click="goToCallCenter">
+				<view class="function-item" @click="goToCallCenter">
 					<view class="function-icon">
 						<Icon name="icon-dianhua" size="60" color="#ff9f43" />
 					</view>
 					<text class="function-name">呼叫留言</text>
-				</view> -->
+				</view>
 
 				<!-- 留言功能 -->
 				<!-- <view class="function-item" @click="goToMessage">

+ 1439 - 332
pages/parent/message.vue

@@ -1,332 +1,1439 @@
-<template>
-	<view class="message-page" :class="screenMode">
-		
-		<!-- 顶部返回 -->
-		<!-- <view class="nav-header" v-if="screenMode != 'heng'">
-			<view class="back-btn" @click="back"><image  src="/static/icon/left.png"></image> 返回</view>
-		</view> -->
-		
-		<!-- 消息列表 -->
-		<view class="message-list">
-            <view class="message-item ">
-                <image class="avatar" src="/static/logo.png"></image>
-                <view class="voice-message">
-					<view class="voice-duration"> <text>你好啊</text></view>
-				</view>
-			</view>
-            <!-- <view class="message-item right">
-                <image class="avatar" src="/static/logo.png"></image>
-                <view class="voice-message">
-					<view class="voice-duration"> <text>注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体注意身体</text></view>
-				</view>
-			</view> -->
-
-            <view class="message-item ">
-                <image class="avatar" src="/static/logo.png"></image>
-                <view class="voice-message">
-					<view class="voice-duration"><image  src="/static/icon/tingtong.png"></image> <text>通话时长 00:25</text></view>
-				</view>
-			</view>
-
-			<!-- 通话记录 -->
-			<view class="message-item right">
-                <image class="avatar" src="/static/logo.png"></image>
-                <view class="voice-message">
-					<view class="voice-duration"><image  src="/static/icon/tingtong.png"></image> <text>通话时长 00:25</text></view>
-				</view>
-			</view>
-			
-			<!-- 语音消息 -->
-			<view class="message-item left">
-				<image class="avatar" src="/static/logo.png"></image>
-				<view class="voice-message">
-					<view class="voice-duration"> <image  src="/static/icon/guangbo.png"></image> <text>24"</text></view>
-					<text class="time">17:25</text>
-				</view>
-			</view>
-			
-			<!-- 语音消息 -->
-			<view class="message-item right">
-                <image class="avatar" src="/static/logo.png"></image>
-				<view class="voice-message">
-					<view class="voice-duration"><image  src="/static/icon/yinbo.png"></image> <text>00:15</text></view>
-					<text class="time">18:28</text>
-				</view>
-			</view>
-			
-			<!-- 语音消息带录音点 -->
-			<view class="message-item left">
-				<image class="avatar" src="/static/logo.png"></image>
-				<view class="voice-message">
-					<view class="voice-duration">
-                        <image  src="/static/icon/guangbo.png"></image>
-                        <text>16"</text>
-						<view class="recording-dot"></view>
-					</view>
-					<text class="time">17:25</text>
-				</view>
-			</view>
-		</view>
-		
-		<!-- 底部操作栏 -->
-		<view class="footer">
-			<view class="input-box">按住 说话</view>
-			<view class="emoji-btn"><image  src="/static/icon/emjoy.png"></image></view>
-		</view>
-	</view>
-</template>
-
-<script>
-export default {
-	data() {
-		return {
-			screenMode: 'heng',
-		}
-	},
-    onLoad(options) {
-		uni.getSystemInfo({
-			success: (res) => {
-				this.screenMode = res.windowWidth > res.windowHeight ? 'heng' : 'shu'
-			}
-		})
-		// 获取路由参数中的角色信息
-		const role = options.role
-        console.log(role)
-        if (!role) {
-            uni.showToast({
-                title: '缺少角色信息',
-                icon: 'none'
-            })
-            return
-        }
-
-		// 这里可以根据角色获取对应的消息列表
-		// this.getMessageList(role)
-	},
-	methods: {
-		back() {
-			uni.navigateBack({
-				delta: 1
-			})
-		}
-	}
-}
-</script>
-
-<style scoped lang="less">
-.heng {
-  min-height: 100vh;
-  background: #f5f5f5;
-  display: flex;
-  flex-direction: column;
-
-  .message-list {
-    flex: 1;
-    padding: 5rpx;
-    background: #fff;
-  }
-  .message-item {
-    display: flex;
-    align-items: flex-start;
-    margin: 15rpx 20rpx;
-  }
-  .avatar {
-    width: 30rpx;
-    height: 30rpx;
-    border-radius: 2rpx;
-    flex-shrink: 0;
-  }
-  .voice-message {
-    position: relative;
-    display: flex;
-    align-items: flex-end;
-  }
-  .voice-duration {
-    background: #a4b3d4;
-    padding: 6rpx;
-    border-radius: 4rpx;
-    color: #fff;
-    font-size: 12rpx;
-    position: relative;
-    min-width: 50rpx;
-    margin: 0 10rpx;
-    display: flex;
-    align-items: center;
-  }
-  .voice-duration image {
-    width: 18rpx;
-    height: 18rpx;
-  }
-  .voice-duration text {
-    margin: 0 5rpx;
-  }
-  .right {
-    flex-direction: row-reverse;
-  }
-  .right .avatar{
-    border-radius: 50%;
-  }
-  .right .voice-message {
-    flex-direction: row-reverse;
-  }
-  .right .voice-duration {
-    background: #e9e9e9;
-    color: #5e5e5e;
-    
-  }
-  .time {
-    font-size: 12rpx;
-    color: #999;
-    display: block;
-  }
-  .recording-dot {
-    width: 5rpx;
-    height: 5rpx;
-    background: #eb6100;
-    border-radius: 50%;
-    position: absolute;
-    right: 3rpx;
-    top: 3rpx;
-  }
-  .footer {
-    padding: 10rpx 35rpx;
-    background: #f5f5f5;
-    display: flex;
-    align-items: center;
-    border-top: 1rpx solid #eee;
-	font-size: 14rpx;
-  }
-  .input-box {
-    flex: 1;
-    background: #ffffff;
-	height: 30rpx;
-    line-height: 30rpx;
-    text-align: center;
-    border-radius: 4rpx;
-    margin-right: 10rpx;
-    color: #666;
-    border: 1rpx solid #eee;
-  }
-  .emoji-btn image {
-    width: 20rpx;
-    height: 20rpx;
-  }
-}
-.shu{
-	min-height: 100vh;
-	background: #f5f5f5;
-	display: flex;
-	flex-direction: column;
-	.nav-header {
-		padding: 20rpx;
-		background: #fff;
-		border-bottom: 1rpx solid #eee;
-	}
-	.back-btn {
-		width: 100rpx;
-		font-size: 28rpx;
-		text-align: center;
-		color: #333;
-		border-radius: 5rpx;
-		padding: 10rpx 20rpx;
-		border: 1px solid #eee;
-		display: flex;
-		align-items: center;
-		gap: 10rpx;
-	}
-	.back-btn image {
-		width: 25rpx;
-		height: 25rpx;
-	}
-	.message-list {
-		flex: 1;
-		padding: 20rpx;
-		background: #fff;
-	}
-	.message-item {
-		display: flex;
-		align-items: flex-start;
-		margin: 30rpx 0;
-	}
-	.avatar {
-		width: 80rpx;
-		height: 80rpx;
-		border-radius: 8rpx;
-		flex-shrink: 0;
-	}
-	.voice-message {
-		position: relative;
-		display: flex;
-		align-items: flex-end;
-	}
-	.voice-duration {
-		background: #a4b3d4;
-		padding: 20rpx 20rpx;
-		border-radius: 8rpx;
-		color: #fff;
-		font-size: 28rpx;
-		position: relative;
-		min-width: 100rpx;
-		margin: 0 20rpx;
-		display: flex;
-		align-items: center;
-	}
-	.voice-duration image{
-		width: 40rpx;
-		height: 40rpx;
-	}
-	.voice-duration text {
-		margin: 0 10rpx;
-	}
-	.right {
-		flex-direction: row-reverse;
-	}
-	.right .avatar{
-		border-radius: 50%;
-	}
-	.right .voice-message {
-		flex-direction: row-reverse;
-	}
-	.right .voice-duration {
-		background: #e9e9e9;
-		color: #5e5e5e;
-		
-	}
-	.time {
-		font-size: 24rpx;
-		color: #999;
-		display: block;
-	}
-	.recording-dot {
-		width: 16rpx;
-		height: 16rpx;
-		background: #eb6100;
-		border-radius: 50%;
-		position: absolute;
-		right: 10rpx;
-		top: 10rpx;
-	}
-	.footer {
-		padding: 20rpx 40rpx;
-		background: #f5f5f5;
-		display: flex;
-		align-items: center;
-		border-top: 1rpx solid #eee;
-	}
-	.input-box {
-		flex: 1;
-		background: #ffffff;
-		height: 80rpx;
-		line-height: 80rpx;
-		text-align: center;
-		border-radius: 8rpx;
-		margin-right: 20rpx;
-		color: #666;
-		border: 1px solid #eee;
-	}
-	.emoji-btn image{
-		width: 50rpx;
-		height: 50rpx;
-	}
-}
-</style>
+<template>
+	<view class="message-page">
+		<!-- 消息列表 -->
+		<scroll-view class="message-list" scroll-y :scroll-top="scrollTop" :scroll-into-view="scrollIntoView"
+			@touchstart="handleListTouchStart" @touchmove="handleListTouchMove" @touchend="handleListTouchEnd">
+			<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" src="/static/logo.png"></image>
+					<text class="msg-time">{{ message.time }}</text>
+				</view>
+
+				<!-- 内容区域 -->
+				<view class="content-section">
+					<!-- 部门/班级 | 姓名 -->
+					<view class="user-info">{{ message.department }} | {{ message.name }}</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>
+								<view v-if="message.needReceipt" class="inline-receipt-button"
+									:class="message.receiptStatus">
+									{{ message.receiptStatus === 'read' ? '已读' : '确认阅读' }}
+								</view>
+							</template>
+							<!-- 发送的消息:按钮在左,内容在右 -->
+							<template v-else>
+								<!-- 带回执按钮(仅在1分钟内显示) -->
+								<view v-if="message.showReceiptToggle" class="receipt-toggle-wrapper">
+									<SsOnoffButton name="needReceipt" label="带回执" :value="1"
+										:modelValue="message.needReceipt ? 1 : 0"
+										@update:modelValue="toggleReceipt(index, $event)" />
+								</view>
+								<!-- 阅读情况按钮 -->
+								<view v-if="message.needReceipt && !message.showReceiptToggle" class="inline-receipt-button"
+									:class="message.receiptStatus">
+									阅读情况
+								</view>
+								<!-- 文字内容 -->
+								<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>{{ message.duration }}" {{ message.voicePreview }}</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>
+
+					<!-- 语音转文字区域(仅语音消息且有转文字内容时显示) -->
+					<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>
+							<view v-if="message.needReceipt" class="receipt-button" :class="message.receiptStatus">
+								{{ message.receiptStatus === 'read' ? '已读' : '确认阅读' }}
+							</view>
+						</template>
+						<!-- 发送的消息:按钮在左,转写在右 -->
+						<template v-else>
+							<view v-if="message.needReceipt" class="receipt-button" :class="message.receiptStatus">
+								阅读情况
+							</view>
+							<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"
+						@longpress="handlePressToTalkStart"
+						@touchmove.stop.prevent="handlePressToTalkMove"
+						@touchend.stop.prevent="handlePressToTalkEnd"
+						@touchcancel.stop.prevent="handlePressToTalkCancel"
+					>
+						<Icon lib="base" name="icon-aud-bold" 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"
+						@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-vid-bold" 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>
+		</view>
+	</template>
+
+<script>
+import SsOnoffButton from '@/components/SsOnoffButton/index.vue'
+import Icon from '@/components/icon/index.vue'
+import { EMOJI_LIST } from '@/constants/emoji'
+import { collectImageUrls, getInitialParentMessages, createImageMessage } from '@/utils/parent-message-factory'
+import {
+	buildTextOutgoingMessage,
+	buildVoiceOutgoingMessage,
+	pickFileOutgoingMessage,
+	pickImageOutgoingMessages,
+	pickVideoOutgoingMessage,
+	shootImageOutgoingMessage
+} from '@/utils/parent-message-send'
+
+const RECEIPT_TOGGLE_TTL_MS = 60 * 1000
+
+	export default {
+		components: {
+			SsOnoffButton,
+			Icon
+		},
+		computed: {},
+		data() {
+		return {
+			scrollTop: 0,
+			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: {},
+			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,
+			audioPlayer: null,
+			playingVoiceIndex: -1,
+			// 消息列表数据
+			messages: getInitialParentMessages()
+			}
+		},
+	onLoad(options) {
+		// 获取路由参数中的角色信息
+		const role = options.role
+		console.log(role)
+		if (!role) {
+			uni.showToast({
+				title: '缺少角色信息',
+				icon: 'none'
+			})
+			return
+		}
+
+		// 这里可以根据角色获取对应的消息列表
+		// this.getMessageList(role)
+	},
+		onReady() {
+			// 页面渲染完成后,滚动到最新消息
+			this.$nextTick(() => {
+				this.scrollToBottom()
+				this.initReceiptToggleTimers()
+				this.initRecorder()
+			})
+		},
+		onUnload() {
+			this.stopVoicePlayback()
+			if (this.audioPlayer) {
+				this.audioPlayer.destroy()
+				this.audioPlayer = null
+			}
+			this.cleanupRecorder()
+			this.clearReceiptToggleTimers()
+		},
+		methods: {
+			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)
+					this.appendMessage(voiceMessage)
+				})
+				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
+			},
+			handlePressToTalkStart(event) {
+				if (this.isRecording) 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() {
+				if (!this.isRecording || !this.recorderManager) return
+				this.recorderManager.stop()
+			},
+			handlePressToTalkCancel() {
+				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') {
+				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) {
+			const touch = event.touches && event.touches[0]
+			if (!touch) return
+			this.listTouchMoved = false
+			this.touchStartX = touch.clientX
+			this.touchStartY = touch.clientY
+		},
+		handleListTouchMove(event) {
+			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() {
+			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)
+				}
+			})
+			this.$nextTick(() => {
+				this.scrollToBottom()
+			})
+		},
+		appendMessage(newMessage) {
+			if (!newMessage) return
+			this.appendMessages([newMessage])
+		},
+		async handleMoreAction(type) {
+			try {
+				if (type === 'file') {
+					const fileMessage = await pickFileOutgoingMessage(this.currentUserMeta)
+					if (fileMessage) this.appendMessage(fileMessage)
+					return
+				}
+				if (type === 'image') {
+					const imageMessages = await pickImageOutgoingMessages(this.currentUserMeta)
+					if (imageMessages.length) this.appendMessages(imageMessages)
+					return
+				}
+				if (type === 'camera') {
+					const imageMessage = await shootImageOutgoingMessage(this.currentUserMeta)
+					if (imageMessage) this.appendMessage(imageMessage)
+					return
+				}
+				if (type === 'video') {
+					const videoMessage = await pickVideoOutgoingMessage(this.currentUserMeta)
+					if (videoMessage) 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
+		},
+		sendTextMessage() {
+			const message = buildTextOutgoingMessage(this.draftText, this.currentUserMeta)
+			if (!message) return
+			message.receiptToggleCreatedAt = Date.now()
+			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;
+
+	.nav-header {
+		padding: 20rpx;
+		background: #fff;
+		border-bottom: 1rpx solid #eee;
+	}
+
+	.back-btn {
+		width: 100rpx;
+		font-size: 28rpx;
+		text-align: center;
+		color: #333;
+		border-radius: 5rpx;
+		padding: 10rpx 20rpx;
+		border: 1px solid #eee;
+		display: flex;
+		align-items: center;
+		gap: 10rpx;
+	}
+
+	.back-btn image {
+		width: 25rpx;
+		height: 25rpx;
+	}
+
+	.message-list {
+		flex: 1;
+		padding: 20rpx;
+		background: #fff;
+		overflow-y: auto;
+		box-sizing: border-box;
+	}
+
+	.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;
+	}
+
+	.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;
+	}
+
+	.message-content {
+		display: flex;
+		align-items: center;
+	}
+
+	/* 文字消息容器 */
+	.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 .content-section {
+		align-items: flex-end;
+	}
+
+	.right .user-info {
+		text-align: right;
+	}
+
+	/* 右侧消息气泡样式 */
+		.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: flex;
+		align-items: flex-start;
+		justify-content: space-between;
+	}
+
+	.more-item {
+		width: 25%;
+		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;
+		}
+
+	}
+</style>

+ 654 - 483
pages/payment/recharge.vue

@@ -1,88 +1,89 @@
-<template>
-	<view class="recharge-page">
-		<!-- 个人服务包选择 -->
-		<view class="package-section">
-			<view class="section-title">选择个人服务包</view>
-
-			<!-- 服务包列表 -->
-			<view v-if="servicePackages.length > 0" class="package-list">
-				<view
-					v-for="pkg in servicePackages"
-					:key="pkg.grfwbid"
-					class="package-item"
-					:class="{ active: selectedPackage && String(selectedPackage.grfwbid) === String(pkg.grfwbid) }"
-					@click="selectPackage(pkg)"
-				>
-					<view class="package-header">
-						<view class="package-name">{{ pkg.mc }}</view>
-						<view class="package-info">
-							<text class="package-price">¥{{ pkg.num > 0 ? (pkg.jg * pkg.num) : pkg.jg }}</text>
-							<text v-if="pkg.num > 0" class="package-weeks">{{ pkg.num }}周 × ¥{{ pkg.jg }}/周</text>
-						</view>
-					</view>
-
-					<!-- 服务项目列表 -->
-					<view v-if="pkg.grfwbmxList && pkg.grfwbmxList.length > 0" class="service-items">
-						<view
-							v-for="(item, index) in pkg.grfwbmxList"
-							:key="index"
-							class="service-item"
-						>
-							<text class="service-name">{{ item.mc }}</text>
-							<view class="service-details">
-								<text v-if="item.sfmf == 1" class="service-tag free">免费</text>
-								<text v-if="item.sc > 0" class="service-tag">{{ item.sc }}分钟</text>
-								<text v-if="item.cs > 0" class="service-tag">{{ item.cs }}次</text>
-								<text v-if="item.ll > 0" class="service-tag">{{ item.ll }}MB</text>
+<template>
+	<view class="subscription-page">
+		<view v-if="servicePackages.length > 0" class="card-list">
+			<view
+				v-for="pkg in servicePackages"
+				:key="pkg.grfwbid"
+				class="service-card"
+				:class="{ expanded: isExpanded(pkg) }"
+				@click="toggleExpand(pkg)"
+			>
+				<view class="card-title">{{ pkg.mc }}</view>
+
+				<view class="card-price-row">
+					<view class="card-price">¥ {{ getPackageTotalPrice(pkg) }}</view>
+					<button class="card-action" @click.stop="toggleExpand(pkg)">
+						{{ isExpanded(pkg) ? '收起' : '订阅' }}
+					</button>
+				</view>
+
+				<template v-if="!isExpanded(pkg)">
+					<view class="card-preview">
+						<rich-text :nodes="getPackageIntroHtml(pkg)"></rich-text>
+					</view>
+				</template>
+
+				<template v-else>
+					<view class="expanded-body" @click.stop>
+						<view class="expanded-intro">
+							<rich-text :nodes="getRichNodes(getPackageIntroHtml(pkg))"></rich-text>
+						</view>
+
+						<view class="detail-section-title">详情</view>
+						<view v-for="(detail, idx) in getPackageDetailList(pkg)" :key="idx" class="detail-item">
+							<view class="detail-item-title">{{ idx + 1 }}. {{ detail.title }}</view>
+							<view class="detail-item-rt">
+								<rich-text :nodes="getRichNodes(detail.html)"></rich-text>
 							</view>
 						</view>
-					</view>
-				</view>
-			</view>
-
-			<!-- 空状态 -->
-			<view v-else class="empty-state">
-				<text>暂无可用服务包</text>
-			</view>
-		</view>
-
-		<!-- 支付金额显示 -->
-		<view class="pay-info">
-			<view class="pay-info-item">
-				<text class="label">支付金额:</text>
-				<text class="value">¥{{ finalAmount }}</text>
-			</view>
-		</view>
-
-	<!-- 支付按钮 -->
-	<view class="pay-btn-wrapper">
-		<button
-			class="pay-btn"
-			:disabled="!finalAmount || finalAmount <= 0"
-			@click="handlePay"
-		>
-			立即购买
-		</button>
-
-		<!-- <button
-			class="pay-btn mock-btn"
-			type="default"
-			@click="goToTestResult"
-		>
-			测试支付结果页
-		</button> -->
-	</view>
-
-		<!-- 充值说明 -->
-		<!-- <view class="tips">
-			<view class="tips-title">充值说明:</view>
-			<view class="tips-item">1. 充值金额将实时到账</view>
-			<view class="tips-item">2. 如有疑问请联系客服</view>
-			<view class="tips-item">3. 充值成功后可在消费记录中查看</view>
-		</view> -->
-	</view>
-</template>
-
+					</view>
+
+					<view class="expanded-footer" @click.stop>
+						<view class="bottom-terms">
+							<view class="checkbox" :class="{ checked: termsAccepted }" @click="toggleTermsAccepted">
+								<text v-if="termsAccepted" class="checkmark">✓</text>
+							</view>
+							
+							<text class="terms-text" @click="openTermsDrawer">《服务条款》</text>
+						</view>
+
+						<button
+							class="bottom-btn"
+							:disabled="isPaymentInProgress || !termsAccepted || !finalAmount || finalAmount <= 0"
+							@click="handlePay"
+						>
+							订阅
+						</button>
+					</view>
+				</template>
+			</view>
+		</view>
+
+		<view v-else class="empty-state">
+			<text>暂无可用服务包</text>
+		</view>
+
+		<u-popup :show="termsDrawerVisible" mode="bottom" round="16" @close="closeTermsDrawer">
+			<view class="terms-drawer">
+				<view class="drawer-head">
+					<view class="drawer-title">服务条款</view>
+				</view>
+				<scroll-view class="drawer-body" scroll-y>
+					<view class="drawer-text">
+						<view class="drawer-p">1. 订阅服务为虚拟服务,一经开通即刻生效。</view>
+						<view class="drawer-p">2. 订阅权益以页面展示及系统实际开通为准。</view>
+						<view class="drawer-p">3. 若出现网络/系统异常导致开通失败,请联系客服处理。</view>
+						<view class="drawer-p">4. 更多条款内容以平台最终版本为准。</view>
+					</view>
+				</scroll-view>
+				<view class="drawer-actions">
+					<button class="drawer-agree" @click="agreeTerms">我已阅读并同意</button>
+				</view>
+			</view>
+		</u-popup>
+	</view>
+</template>
+
 <script setup>
 import { ref, computed } from 'vue'
 import { onLoad } from '@dcloudio/uni-app'
@@ -116,414 +117,584 @@ const normalizePayParams = (res) => {
 		orderId: data.outTradeNo
 	}
 }
-
-// 个人服务包数据
-const servicePackages = ref([])  // 服务包列表
-const selectedPackage = ref(null)  // 选中的服务包
-
-const isPaymentInProgress = ref(false)  // 支付进行中标志
-
-// 最终支付金额(使用服务包价格,周包需要乘以周数)
-const finalAmount = computed(() => {
-	if (selectedPackage.value) {
-		const pkg = selectedPackage.value
-		// 如果是周包(num > 0),总价 = 单价 × 周数
-		if (pkg.num > 0) {
-			return parseFloat(pkg.jg * pkg.num) || 0
-		}
-		return parseFloat(pkg.jg) || 0
-	}
-	return 0
-})
-
-// 选择服务包
-const selectPackage = (pkg) => {
-	selectedPackage.value = pkg
-	console.log('选中服务包:', pkg)
-}
-
-// 发起支付
-const handlePay = async () => {
-	if (isPaymentInProgress.value) {
-		console.log('支付进行中,请勿重复点击')
-		return
-	}
-
-	// 检查是否选择了服务包
-	if (!selectedPackage.value) {
-		uni.showToast({
-			title: '请选择服务包',
-			icon: 'none'
-		})
-		return
-	}
-
-	const amount = finalAmount.value
-	if (!amount || amount <= 0) {
-		uni.showToast({
-			title: '支付金额异常',
-			icon: 'none'
-		})
-		return
-	}
-
-	try {
-		isPaymentInProgress.value = true
-
-		// 1. 创建订单,获取支付参数
-		console.log('创建个人服务包订单,包ID:', selectedPackage.value.grfwbid)
-		console.log('支付金额:', amount)
-
-		const payParamsRaw = await grfwApi.grfw_prepayGrfwb({
-			grfwbid: selectedPackage.value.grfwbid
-		})
-		console.log('创建订单接口原始返回:', payParamsRaw.data.ssData)
-
-		const payParams = normalizePayParams(payParamsRaw)
-		if (!payParams.timeStamp || !payParams.nonceStr || !payParams.package || !payParams.paySign) {
-			throw new Error('支付参数不完整')
-		}
-
-		console.log('支付参数:', payParams)
-
-		// 2. 调用微信支付
-		const paymentResult = await uni.requestPayment({
-			provider: 'wxpay',
-			timeStamp: String(payParams.timeStamp),
-			nonceStr: payParams.nonceStr,
-			package: payParams.package,
-			signType: payParams.signType || 'RSA',
-			paySign: payParams.paySign,
-		})
-
-		console.log('支付结果:', paymentResult)
-
-		// 3. 支付成功 - 跳转到支付结果页面
-		const resultOrderId = payParams.orderId
-
-		if (resultOrderId) {
-			// 跳转到支付结果页面,进行订单状态查询,传递 grfwbid 用于确认服务
-			uni.redirectTo({
-				url: `/pages/payment/result?orderId=${resultOrderId}&grfwbid=${selectedPackage.value.grfwbid}`
-			})
-		} else {
-			// 如果没有订单ID,直接提示成功
-			uni.showToast({
-				title: '支付成功',
-				icon: 'success',
-				duration: 2000
-			})
-			setTimeout(() => {
-				uni.navigateBack()
-			}, 2000)
-		}
-
-	} catch (error) {
-		console.error('支付失败:', error)
-
-		// 处理不同的错误情况
-		if (error.errMsg) {
-			// 微信支付相关错误
-			if (error.errMsg.includes('cancel')) {
-				uni.showToast({
-					title: '已取消支付',
-					icon: 'none'
-				})
-			} else if (error.errMsg.includes('fail')) {
-				uni.showToast({
-					title: '支付失败,请重试',
-					icon: 'none'
-				})
-			}
-		} else {
-			// 其他错误(比如创建订单失败)
-			uni.showToast({
-				title: error.message || '操作失败',
-				icon: 'none'
-			})
-		}
-	} finally {
-		isPaymentInProgress.value = false
-	}
-}
-
+
+// 个人服务包数据
+const servicePackages = ref([])  // 服务包列表
+const selectedPackage = ref(null)  // 选中的服务包
+
+const isPaymentInProgress = ref(false)  // 支付进行中标志
+const expandedPackageId = ref('')
+const termsAccepted = ref(false)
+const termsDrawerVisible = ref(false)
+
+const buildIntroHtml = (rawText) => {
+	if (!rawText || typeof rawText !== 'string') return ''
+	const lines = rawText
+		.split(/\r?\n/)
+		.map((line) => line.trim())
+		.filter(Boolean)
+	if (!lines.length) return ''
+	return lines
+		.map((line) => `<p>${line}</p>`)
+		.join('')
+}
+
+const normalizeServicePackages = (ssData) => {
+	if (!Array.isArray(ssData)) return []
+
+	// ssData[0] 按需求跳过,从 ssData[1] 开始读取服务包
+	return ssData
+		.slice(1)
+		.filter((pkg) => pkg && pkg.grfwbid)
+		.map((pkg) => {
+			const detailItems = Array.isArray(pkg.grfwbmxList)
+				? [...pkg.grfwbmxList].sort((a, b) => (a?.xh || 0) - (b?.xh || 0))
+				: []
+
+			return {
+				...pkg,
+				jg: parseFloat(pkg.jg) || 0,
+				num: Number(pkg.num) || 0,
+				introHtml: buildIntroHtml(pkg.ms),
+				detailList: detailItems.map((item) => ({
+					title: item?.mc || '',
+					html: `<p>${item?.ms || getServiceDesc(item)}</p>`
+				}))
+			}
+		})
+}
+
+// 最终支付金额(使用服务包价格,周包需要乘以周数)
+const finalAmount = computed(() => {
+	if (selectedPackage.value) {
+		const pkg = selectedPackage.value
+		// 如果是周包(num > 0),总价 = 单价 × 周数
+		if (pkg.num > 0) {
+			return parseFloat(pkg.jg * pkg.num) || 0
+		}
+		return parseFloat(pkg.jg) || 0
+	}
+	return 0
+})
+
+// 选择服务包
+const selectPackage = (pkg) => {
+	selectedPackage.value = pkg
+	console.log('选中服务包:', pkg)
+}
+
+const getPackageTotalPrice = (pkg) => {
+	if (!pkg) return 0
+	if (pkg.num > 0) {
+		return parseFloat(pkg.jg * pkg.num) || 0
+	}
+	return parseFloat(pkg.jg) || 0
+}
+
+const formatServiceLine = (item) => {
+	if (!item) return ''
+	if (item.sc > 0) return `${item.mc}:${item.sc}分钟`
+	if (item.cs > 0) return `${item.mc}:${item.cs}次`
+	if (item.ll > 0) return `${item.mc}:${item.ll}MB`
+	return `${item.mc}`
+}
+
+const getPackageIntroHtml = (pkg) => {
+	if (!pkg) return ''
+	if (pkg.introHtml) return pkg.introHtml
+	const list = pkg?.grfwbmxList || []
+	const lines = list.map(formatServiceLine).filter(Boolean)
+	return lines.map((line) => `<p>• ${line}</p>`).join('')
+}
+
+const getPackageDetailList = (pkg) => {
+	if (!pkg) return []
+	if (Array.isArray(pkg.detailList) && pkg.detailList.length > 0) {
+		return pkg.detailList
+	}
+	const list = pkg?.grfwbmxList || []
+	return list.map((item) => ({
+		title: item?.mc || '',
+		html: `<p>${getServiceDesc(item)}</p>`
+	}))
+}
+
+const getRichNodes = (html) => {
+	if (!html) return ''
+	// 通过内联样式控制 rich-text 的基础排版(不同端对外部样式支持不一致)
+	return `<div style="font-size: 36rpx; line-height: 36rpx; color: #000;">${html}</div>`
+}
+
+const getServiceDesc = (item) => {
+	if (!item) return ''
+	if (item.sfmf == 1) {
+		return '该服务为免费项,订阅后可直接使用。'
+	}
+	if (item.sc > 0) {
+		return `包含可用时长:${item.sc} 分钟。`
+	}
+	if (item.cs > 0) {
+		return `包含可用次数:${item.cs} 次。`
+	}
+	if (item.ll > 0) {
+		return `包含可用流量:${item.ll} MB。`
+	}
+	return '订阅后可使用该服务。'
+}
+
+const openDetail = (pkg) => {
+	selectPackage(pkg)
+	termsAccepted.value = false
+	expandedPackageId.value = String(pkg?.grfwbid || '')
+}
+
+const collapseDetail = () => {
+	expandedPackageId.value = ''
+	termsAccepted.value = false
+}
+
+const isExpanded = (pkg) => String(pkg?.grfwbid || '') === expandedPackageId.value
+
+const toggleExpand = (pkg) => {
+	const pkgId = String(pkg?.grfwbid || '')
+	if (!pkgId) return
+	if (expandedPackageId.value === pkgId) {
+		collapseDetail()
+		return
+	}
+	openDetail(pkg)
+}
+
+const openTermsDrawer = () => {
+	termsDrawerVisible.value = true
+}
+
+const closeTermsDrawer = () => {
+	termsDrawerVisible.value = false
+}
+
+const agreeTerms = () => {
+	termsAccepted.value = true
+	termsDrawerVisible.value = false
+}
+
+const toggleTermsAccepted = () => {
+	termsAccepted.value = !termsAccepted.value
+}
+
+// 发起支付
+const handlePay = async () => {
+	if (isPaymentInProgress.value) {
+		console.log('支付进行中,请勿重复点击')
+		return
+	}
+
+	// 检查是否选择了服务包
+	if (!selectedPackage.value) {
+		uni.showToast({
+			title: '请选择服务包',
+			icon: 'none'
+		})
+		return
+	}
+
+	const amount = finalAmount.value
+	if (!amount || amount <= 0) {
+		uni.showToast({
+			title: '支付金额异常',
+			icon: 'none'
+		})
+		return
+	}
+
+	try {
+		isPaymentInProgress.value = true
+
+		// 1. 创建订单,获取支付参数
+		console.log('创建个人服务包订单,包ID:', selectedPackage.value.grfwbid)
+		console.log('支付金额:', amount)
+
+		const payParamsRaw = await grfwApi.grfw_prepayGrfwb({
+			grfwbid: selectedPackage.value.grfwbid
+		})
+		console.log('创建订单接口原始返回:', payParamsRaw.data.ssData)
+
+		const payParams = normalizePayParams(payParamsRaw)
+		if (!payParams.timeStamp || !payParams.nonceStr || !payParams.package || !payParams.paySign) {
+			throw new Error('支付参数不完整')
+		}
+
+		console.log('支付参数:', payParams)
+
+		// 2. 调用微信支付
+		const paymentResult = await uni.requestPayment({
+			provider: 'wxpay',
+			timeStamp: String(payParams.timeStamp),
+			nonceStr: payParams.nonceStr,
+			package: payParams.package,
+			signType: payParams.signType || 'RSA',
+			paySign: payParams.paySign,
+		})
+
+		console.log('支付结果:', paymentResult)
+
+		// 3. 支付成功 - 跳转到支付结果页面
+		const resultOrderId = payParams.orderId
+
+		if (resultOrderId) {
+			// 跳转到支付结果页面,进行订单状态查询,传递 grfwbid 用于确认服务
+			uni.redirectTo({
+				url: `/pages/payment/result?orderId=${resultOrderId}&grfwbid=${selectedPackage.value.grfwbid}`
+			})
+		} else {
+			// 如果没有订单ID,直接提示成功
+			uni.showToast({
+				title: '支付成功',
+				icon: 'success',
+				duration: 2000
+			})
+			setTimeout(() => {
+				uni.navigateBack()
+			}, 2000)
+		}
+
+	} catch (error) {
+		console.error('支付失败:', error)
+
+		// 处理不同的错误情况
+		if (error.errMsg) {
+			// 微信支付相关错误
+			if (error.errMsg.includes('cancel')) {
+				uni.showToast({
+					title: '已取消支付',
+					icon: 'none'
+				})
+			} else if (error.errMsg.includes('fail')) {
+				uni.showToast({
+					title: '支付失败,请重试',
+					icon: 'none'
+				})
+			}
+		} else {
+			// 其他错误(比如创建订单失败)
+			uni.showToast({
+				title: error.message || '操作失败',
+				icon: 'none'
+			})
+		}
+	} finally {
+		isPaymentInProgress.value = false
+	}
+}
+
 // 加载个人服务包列表
 const loadServicePackages = async () => {
 	try {
 		const res = await grfwApi.grfw_initGrfwbBuy({})
-		console.log('获取服务包列表返回:', res)
-		console.log('res.data:', res.data)
-		console.log('res.data.ssData:', res.data?.ssData)
-
-		// 数据在 res.data.ssData 里
-		if (res && res.data && res.data.ssData && Array.isArray(res.data.ssData)) {
-			const ssData = res.data.ssData
-			console.log('ssData 长度:', ssData.length)
-			console.log('ssData 内容:', JSON.stringify(ssData))
-
-			// 跳过第一个元素(minJzsj),从第二个元素开始获取服务包
-			if (ssData.length > 1) {
-				const packages = ssData.slice(1)
-				console.log('提取的服务包(去除第一个元素):', packages)
-
-				servicePackages.value = packages.filter(item => item.grfwbid)
-				console.log('过滤后的服务包列表:', servicePackages.value)
-				console.log('服务包数量:', servicePackages.value.length)
-			} else {
-				console.log('ssData 长度不足,没有服务包数据')
-			}
-		} else {
-			console.log('res.data.ssData 不存在或不是数组')
-		}
+		console.log(res)
+		const packages = normalizeServicePackages(res?.data?.ssData)
+
+		if (!packages.length) {
+			throw new Error('暂无可用服务包')
+		}
+
+		servicePackages.value = packages
+		selectedPackage.value = packages[0] || null
+		termsAccepted.value = false
+		expandedPackageId.value = ''
 	} catch (error) {
 		console.error('加载服务包失败:', error)
+		servicePackages.value = []
+		selectedPackage.value = null
 		uni.showToast({
-			title: '加载服务包失败',
+			title: error?.message || '加载服务包失败',
 			icon: 'none'
 		})
 	}
 }
-
-// 页面加载
-onLoad((options) => {
-	console.log('充值页面加载,参数:', options)
-
-	// 加载服务包列表
-	loadServicePackages()
-})
-
-// 测试跳转到支付结果页
-const goToTestResult = () => {
-	// 使用固定的订单号测试
-	const testOrderId = 'pmsgrfwb7m05clpYYgwHG8qlroZB1'
-	const testGrfwbid = selectedPackage.value?.grfwbid || ''
-
-	uni.redirectTo({
-		url: `/pages/payment/result?orderId=${testOrderId}&grfwbid=${testGrfwbid}`
-	})
-}
-</script>
-
-<style lang="scss" scoped>
-.recharge-page {
-	min-height: 100vh;
-	background: #f5f5f5;
-	padding: 30rpx;
-}
-
-.package-section {
-	background: #fff;
-	border-radius: 20rpx;
-	padding: 40rpx 30rpx;
-	margin-bottom: 30rpx;
-}
-
-.section-title {
-	font-size: 32rpx;
-	font-weight: bold;
-	color: #333;
-	margin-bottom: 30rpx;
-}
-
-.package-list {
-	display: flex;
-	flex-direction: column;
-	gap: 20rpx;
-}
-
-.package-item {
-	padding: 30rpx;
-	border: 2rpx solid #e5e5e5;
-	border-radius: 12rpx;
-	background: #fff;
-	transition: all 0.3s;
-}
-
-.package-item.active {
-	border-color: #07c160;
-	background: #f0f9ff;
-}
-
-.package-header {
-	display: flex;
-	justify-content: space-between;
-	align-items: center;
-	margin-bottom: 20rpx;
-}
-
-.package-name {
-	font-size: 32rpx;
-	color: #333;
-	font-weight: 500;
-}
-
-.package-item.active .package-name {
-	color: #07c160;
-	font-weight: bold;
-}
-
-.package-info {
-	display: flex;
-	flex-direction: column;
-	align-items: flex-end;
-	gap: 8rpx;
-}
-
-.package-price {
-	font-size: 36rpx;
-	color: #ff4d4f;
-	font-weight: bold;
-}
-
-.package-item.active .package-price {
-	color: #07c160;
-}
-
-.package-weeks {
-	font-size: 24rpx;
-	color: #999;
-	background: #f5f5f5;
-	padding: 4rpx 12rpx;
-	border-radius: 4rpx;
-}
-
-.package-item.active .package-weeks {
-	background: #e6f7ff;
-	color: #1890ff;
-}
-
-.service-items {
-	border-top: 1rpx solid #f0f0f0;
-	padding-top: 20rpx;
-	display: grid;
-	grid-template-columns: repeat(2, 1fr);
-	gap: 12rpx;
-}
-
-.service-item {
-	display: flex;
-	flex-direction: column;
-	gap: 8rpx;
-	padding: 12rpx 16rpx;
-	background: #fafafa;
-	border-radius: 8rpx;
-}
-
-.package-item.active .service-item {
-	background: #f0f9ff;
-}
-
-.service-name {
-	font-size: 28rpx;
-	color: #666;
-	font-weight: 500;
-}
-
-.service-details {
-	display: flex;
-	flex-wrap: wrap;
-	gap: 6rpx;
-}
-
-.service-tag {
-	font-size: 22rpx;
-	color: #666;
-	background: #fff;
-	padding: 2rpx 8rpx;
-	border-radius: 4rpx;
-	border: 1rpx solid #e5e5e5;
-	white-space: nowrap;
-}
-
-.service-tag.free {
-	color: #52c41a;
-	background: #f6ffed;
-	border-color: #b7eb8f;
-}
-
-.empty-state {
-	text-align: center;
-	padding: 60rpx 0;
-	color: #999;
-	font-size: 28rpx;
-}
-
-.pay-info {
-	background: #fff;
-	border-radius: 20rpx;
-	padding: 30rpx;
-	margin-bottom: 30rpx;
-}
-
-.pay-info-item {
-	display: flex;
-	justify-content: space-between;
-	align-items: center;
-}
-
-.pay-info-item .label {
-	font-size: 32rpx;
-	color: #333;
-}
-
-.pay-info-item .value {
-	font-size: 40rpx;
-	color: #ff4d4f;
-	font-weight: bold;
-}
-
-.pay-btn-wrapper {
-	padding: 40rpx 0;
-}
-
-.pay-btn {
-	width: 100%;
-	height: 88rpx;
-	line-height: 88rpx;
-	background: linear-gradient(135deg, #07c160 0%, #05a050 100%);
-	color: #fff;
-	border-radius: 44rpx;
-	font-size: 32rpx;
-	font-weight: bold;
-	border: none;
-	box-shadow: 0 8rpx 20rpx rgba(7, 193, 96, 0.3);
-}
-
-.pay-btn[disabled] {
-	background: #e5e5e5;
-	color: #999;
-	box-shadow: none;
-}
-
-.pay-btn::after {
-	border: none;
-}
-
-.mock-btn {
-	margin-top: 20rpx;
-	background: #f5f5f5;
-	color: #333;
-	border: 1px solid #d9d9d9;
-	box-shadow: none;
-}
-
-.tips {
-	background: #fff;
-	border-radius: 20rpx;
-	padding: 30rpx;
-}
-
-.tips-title {
-	font-size: 28rpx;
-	color: #333;
-	font-weight: bold;
-	margin-bottom: 20rpx;
-}
-
-.tips-item {
-	font-size: 26rpx;
-	color: #999;
-	line-height: 40rpx;
-	margin-bottom: 10rpx;
-}
-</style>
+
+// 页面加载
+onLoad((options) => {
+	console.log('充值页面加载,参数:', options)
+
+	// 加载服务包列表
+	loadServicePackages()
+})
+
+// 测试跳转到支付结果页
+const goToTestResult = () => {
+	// 使用固定的订单号测试
+	const testOrderId = 'pmsgrfwb7m05clpYYgwHG8qlroZB1'
+	const testGrfwbid = selectedPackage.value?.grfwbid || ''
+
+	uni.redirectTo({
+		url: `/pages/payment/result?orderId=${testOrderId}&grfwbid=${testGrfwbid}`
+	})
+}
+</script>
+
+<style lang="scss" scoped>
+.subscription-page {
+	min-height: 100vh;
+	background: #fff;
+	padding: 24rpx;
+	box-sizing: border-box;
+}
+
+.card-list {
+	display: flex;
+	flex-direction: column;
+	gap: 30rpx;
+}
+
+.service-card {
+	background: #fafafb;
+	border-radius: 6rpx;
+	padding:  40rpx 50rpx 50rpx;
+	box-shadow: 0 7rpx 10rpx rgba(0, 0, 0, 0.2);
+	border: 2rpx solid #cbcbcb;
+}
+
+.service-card.expanded {
+}
+
+.card-price-row {
+	display: flex;
+	justify-content: space-between;
+	align-items: center;
+	gap: 18rpx;
+	margin-top: 10rpx;
+}
+
+.card-title {
+	font-size: 48rpx;
+	// font-weight: 700;
+	color: #000;
+	line-height: 48rpx;
+}
+
+.card-price {
+	font-size: 48rpx;
+	// font-weight: 700;
+	color: #000;
+}
+
+.card-action {
+	height: 50rpx;
+    line-height: 50rpx;
+    width: 110rpx;
+	border-radius: 6rpx;
+	background: #3a3e51;
+	color: #fff;
+	font-size: 26rpx;
+	border: none;
+	margin: 0;
+	flex-shrink: 0;
+}
+
+.card-action::after {
+	border: none;
+}
+
+.card-preview {
+	margin-top: 18rpx;
+	display: -webkit-box;
+	-webkit-line-clamp: 3;
+	-webkit-box-orient: vertical;
+	overflow: hidden;
+}
+
+.expanded-intro {
+	// padding-top: 10rpx;
+}
+
+.card-preview :deep(p),
+.expanded-intro :deep(p) {
+	margin: 0;
+	padding: 0;
+	font-size: 26rpx;
+	line-height: 36rpx;
+	color: #000;
+}
+
+.detail-item-rt {
+	margin-top: 8rpx;
+}
+
+.detail-item-rt :deep(p),
+.detail-item-rt :deep(div),
+.detail-item-rt :deep(span),
+.detail-item-rt :deep(li) {
+	margin: 0;
+	padding: 0;
+	font-size: 26rpx;
+	line-height: 36rpx;
+	color: #000;
+}
+
+.detail-item-rt :deep(b),
+.detail-item-rt :deep(strong) {
+	font-weight: 700;
+	color: #111;
+}
+
+.empty-state {
+	text-align: center;
+	padding: 80rpx 0;
+	color: #999;
+	font-size: 28rpx;
+}
+
+.detail-section-title {
+	margin-top: 40rpx;
+	margin-bottom: 12rpx;
+    font-size: 48rpx;
+    color: #000;
+    line-height: 48rpx;
+}
+
+.detail-item {
+	padding: 14rpx 0;
+}
+
+.detail-item-title {
+	font-size: 36rpx;
+    font-weight: bold;
+    color: #000;
+}
+
+.detail-item-desc {
+	margin-top: 8rpx;
+	font-size: 26rpx;
+	line-height: 36rpx;
+	color: #7b8597;
+}
+
+.expanded-body {
+	margin-top: 18rpx;
+}
+
+.terms-text {
+	font-size: 36rpx;
+	color: #0030ab;
+}
+
+.expanded-footer {
+	padding-top: 16rpx;
+}
+
+.bottom-terms {
+	display: flex;
+	align-items: center;
+	gap: 10rpx;
+	padding: 0 6rpx 14rpx;
+}
+
+
+
+.checkbox {
+	width: 34rpx;
+	height: 34rpx;
+	border-radius: 6rpx;
+	border: 2rpx solid #b8b8b8;
+	background: #fff;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	box-sizing: border-box;
+}
+
+.checkbox.checked {
+	border-color: #3a3e51;
+	background: #3a3e51;
+}
+
+.checkmark {
+	color: #fff;
+	font-size: 24rpx;
+	line-height: 24rpx;
+	font-weight: 700;
+}
+
+.bottom-btn {
+	width: 100%;
+    height: 70rpx;
+    line-height: 70rpx;
+	border-radius: 6rpx;
+	background: #3a3e51;
+	color: #fff;
+	font-size: 40rpx;
+	// font-weight: 700;
+	border: none;
+}
+
+.bottom-btn[disabled] {
+	background: #3a3e51;
+	color: #fff;
+}
+
+.bottom-btn::after {
+	border: none;
+}
+
+.terms-drawer {
+	background: #fff;
+	padding: 22rpx 24rpx 0;
+	max-height: 80vh;
+	box-sizing: border-box;
+	display: flex;
+	flex-direction: column;
+}
+
+.drawer-head {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	padding-bottom: 16rpx;
+	border-bottom: 1rpx solid #f0f0f0;
+}
+
+.drawer-title {
+	font-size: 32rpx;
+	font-weight: 700;
+	color: #111;
+}
+
+.drawer-body {
+	flex: 1;
+	min-height: 0;
+	padding: 16rpx 0;
+}
+
+.drawer-text {
+	padding-right: 10rpx;
+}
+
+.drawer-p {
+	font-size: 26rpx;
+	line-height: 40rpx;
+	color: #444;
+	margin-bottom: 12rpx;
+}
+
+.drawer-actions {
+	margin-top: auto;
+	padding-top: 14rpx;
+}
+
+.drawer-agree {
+	width: 100%;
+	height: 88rpx;
+	line-height: 88rpx;
+	border-radius: 6rpx;
+	background: #3a3e51;
+	color: #fff;
+	font-size: 30rpx;
+	font-weight: 700;
+	border: none;
+}
+
+.drawer-agree::after {
+	border: none;
+}
+</style>

BIN
static/.DS_Store


+ 264 - 0
static/iconfont/icon-base/iconfont.css

@@ -0,0 +1,264 @@
+@font-face {
+  font-family: "icon-base"; /* Project id 5081296 */
+  src: url('iconfont.woff2') format('woff2'),
+       url('iconfont.woff') format('woff'),
+       url('iconfont.ttf') format('truetype'),
+       url('iconfont.svg#icon-base') format('svg');
+}
+
+.icon-base {
+  font-family: "icon-base" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-jieshu:before {
+  content: "\e670";
+}
+
+.icon-kaishi:before {
+  content: "\e66f";
+}
+
+.icon-sanjiaogantanhao:before {
+  content: "\e66e";
+}
+
+.icon-fauth:before {
+  content: "\e66b";
+}
+
+.icon-sauth:before {
+  content: "\e66a";
+}
+
+.icon-fs-exit:before {
+  content: "\e669";
+}
+
+.icon-fs:before {
+  content: "\e666";
+}
+
+.icon-att:before {
+  content: "\e668";
+}
+
+.icon-cardChg:before {
+  content: "\e664";
+}
+
+.icon-cardSus:before {
+  content: "\e665";
+}
+
+.icon-cardAdd:before {
+  content: "\e667";
+}
+
+.icon-cardChk-off:before {
+  content: "\e663";
+}
+
+.icon-spk:before {
+  content: "\e660";
+}
+
+.icon-vid:before {
+  content: "\e661";
+}
+
+.icon-img:before {
+  content: "\e662";
+}
+
+.icon-img-bold:before {
+  content: "\e65f";
+}
+
+.icon-file:before {
+  content: "\e65e";
+}
+
+.icon-vid-bold:before {
+  content: "\e65b";
+}
+
+.icon-aud-bold:before {
+  content: "\e65c";
+}
+
+.icon-txt:before {
+  content: "\e65d";
+}
+
+.icon-cardChk-on:before {
+  content: "\e659";
+}
+
+.icon-cardChk:before {
+  content: "\e65a";
+}
+
+.icon-emoji:before {
+  content: "\e658";
+}
+
+.icon-chk-on:before {
+  content: "\e657";
+}
+
+.icon-chk:before {
+  content: "\e656";
+}
+
+.icon-ptab:before {
+  content: "\e655";
+}
+
+.icon-pval:before {
+  content: "\e654";
+}
+
+.icon-autoTxt:before {
+  content: "\e649";
+}
+
+.icon-autoTxt-bold:before {
+  content: "\e648";
+}
+
+.icon-fixTxt:before {
+  content: "\e647";
+}
+
+.icon-fixTxt-bold:before {
+  content: "\e646";
+}
+
+.icon-fix:before {
+  content: "\e644";
+}
+
+.icon-fix-bold:before {
+  content: "\e645";
+}
+
+.icon-voice:before {
+  content: "\e642";
+}
+
+.icon-set:before {
+  content: "\e641";
+}
+
+.icon-subm:before {
+  content: "\e63f";
+}
+
+.icon-mute:before {
+  content: "\e630";
+}
+
+.icon-voice-bold:before {
+  content: "\e62f";
+}
+
+.icon-search:before {
+  content: "\e62e";
+}
+
+.icon-chg:before {
+  content: "\e62d";
+}
+
+.icon-baseInfo:before {
+  content: "\e62c";
+}
+
+.icon-close:before {
+  content: "\e62b";
+}
+
+.icon-cl:before {
+  content: "\e62a";
+}
+
+.icon-save:before {
+  content: "\e629";
+}
+
+.icon-add:before {
+  content: "\e628";
+}
+
+.icon-task:before {
+  content: "\e620";
+}
+
+.icon-urge:before {
+  content: "\e621";
+}
+
+.icon-help-bold:before {
+  content: "\e61e";
+}
+
+.icon-help:before {
+  content: "\e61f";
+}
+
+.icon-skin:before {
+  content: "\e61c";
+}
+
+.icon-set-bold:before {
+  content: "\e61d";
+}
+
+.icon-lock:before {
+  content: "\e61b";
+}
+
+.icon-home:before {
+  content: "\e619";
+}
+
+.icon-lock-bold:before {
+  content: "\e61a";
+}
+
+.icon-user-sw:before {
+  content: "\e618";
+}
+
+.icon-shak:before {
+  content: "\e615";
+}
+
+.icon-login:before {
+  content: "\e616";
+}
+
+.icon-exit:before {
+  content: "\e617";
+}
+
+.icon-time:before {
+  content: "\e614";
+}
+
+.icon-captcha:before {
+  content: "\e613";
+}
+
+.icon-pwd:before {
+  content: "\e612";
+}
+
+.icon-user:before {
+  content: "\e611";
+}
+

BIN
static/iconfont/icon-base/iconfont.ttf


BIN
static/iconfont/icon-base/iconfont.woff


BIN
static/iconfont/icon-base/iconfont.woff2


+ 212 - 0
static/iconfont/icon-biz/iconfont.css

@@ -0,0 +1,212 @@
+@font-face {
+  font-family: "icon-biz"; /* Project id 5081293 */
+  src: url('iconfont.woff2?') format('woff2'),
+       url('iconfont.woff?') format('woff'),
+       url('iconfont.ttf?') format('truetype'),
+       url('iconfont.svg?#icon-biz') format('svg');
+}
+
+.icon-biz {
+  font-family: "icon-biz" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-obj-xczd:before {
+  content: "\e672";
+}
+
+.icon-obj-xcdm:before {
+  content: "\e673";
+}
+
+.icon-biz-js:before {
+  content: "\e671";
+}
+
+.icon-obj-qj:before {
+  content: "\e670";
+}
+
+.icon-obj-wxz:before {
+  content: "\e66d";
+}
+
+.icon-obj-qjz:before {
+  content: "\e66e";
+}
+
+.icon-obj-fw:before {
+  content: "\e66f";
+}
+
+.icon-obj-jtcy:before {
+  content: "\e66c";
+}
+
+.icon-obj-xiaoq:before {
+  content: "\e66b";
+}
+
+.icon-obj-xiaoqqy:before {
+  content: "\e66a";
+}
+
+.icon-obj-lc:before {
+  content: "\e669";
+}
+
+.icon-obj-bm:before {
+  content: "\e668";
+}
+
+.icon-obj-yd:before {
+  content: "\e663";
+}
+
+.icon-obj-qzcy:before {
+  content: "\e653";
+}
+
+.icon-biz-cy:before {
+  content: "\e652";
+}
+
+.icon-biz-rc:before {
+  content: "\e651";
+}
+
+.icon-obj-pj:before {
+  content: "\e650";
+}
+
+.icon-obj-wplymb:before {
+  content: "\e64f";
+}
+
+.icon-obj-wplb:before {
+  content: "\e64e";
+}
+
+.icon-obj-gys:before {
+  content: "\e64d";
+}
+
+.icon-obj-cd:before {
+  content: "\e64c";
+}
+
+.icon-obj-jzw:before {
+  content: "\e64b";
+}
+
+.icon-biz-cd:before {
+  content: "\e64a";
+}
+
+.icon-obj-cp:before {
+  content: "\e641";
+}
+
+.icon-obj-mtcp:before {
+  content: "\e642";
+}
+
+.icon-obj-kqjl:before {
+  content: "\e63f";
+}
+
+.icon-biz-kq:before {
+  content: "\e640";
+}
+
+.icon-biz-wp:before {
+  content: "\e63d";
+}
+
+.icon-obj-wp:before {
+  content: "\e63e";
+}
+
+.icon-biz-cl:before {
+  content: "\e63c";
+}
+
+.icon-obj-cl:before {
+  content: "\e63b";
+}
+
+.icon-obj-xcd:before {
+  content: "\e63a";
+}
+
+.icon-biz-xc:before {
+  content: "\e638";
+}
+
+.icon-obj-xcdjl:before {
+  content: "\e639";
+}
+
+.icon-obj-mjd:before {
+  content: "\e637";
+}
+
+.icon-obj-mjdJcjl:before {
+  content: "\e636";
+}
+
+.icon-biz-mj:before {
+  content: "\e635";
+}
+
+.icon-obj-xfj:before {
+  content: "\e634";
+}
+
+.icon-biz-gxfw:before {
+  content: "\e633";
+}
+
+.icon-obj-grcz:before {
+  content: "\e632";
+}
+
+.icon-biz-xy:before {
+  content: "\e627";
+}
+
+.icon-obj-xy:before {
+  content: "\e626";
+}
+
+.icon-obj-dw:before {
+  content: "\e625";
+}
+
+.icon-obj-gw:before {
+  content: "\e623";
+}
+
+.icon-obj-qz:before {
+  content: "\e624";
+}
+
+.icon-obj-ry:before {
+  content: "\e622";
+}
+
+.icon-biz-ry:before {
+  content: "\e621";
+}
+
+.icon-obj-rcjh:before {
+  content: "\e601";
+}
+
+.icon-obj-rc:before {
+  content: "\e600";
+}
+

BIN
static/iconfont/icon-biz/iconfont.ttf


BIN
static/iconfont/icon-biz/iconfont.woff


BIN
static/iconfont/icon-biz/iconfont.woff2


+ 1 - 1
unpackage/dist/dev/.sourcemap/mp-weixin/common/assets.js.map

@@ -1 +1 @@
-{"version":3,"file":"assets.js","sources":["static/images/warning.gif","static/images/strat.svg","static/images/end.svg","static/images/bus.svg","static/logo.png","static/icon/tingtong.png","static/icon/guangbo.png","static/icon/yinbo.png","static/icon/emjoy.png","static/icon/audio.png","static/icon/video.png","static/icon/message.png","static/icon/logout.png","static/images/deviceunlogin.png"],"sourcesContent":["export default \"__VITE_ASSET__dba43bae__\"","export default \"__VITE_ASSET__393b1428__\"","export default \"__VITE_ASSET__7a909323__\"","export default \"__VITE_ASSET__257f5630__\"","export default \"__VITE_ASSET__0d1a51c4__\"","export default \"__VITE_ASSET__74cc0994__\"","export default \"__VITE_ASSET__026192e1__\"","export default \"__VITE_ASSET__25a6ca73__\"","export default \"__VITE_ASSET__b63618f6__\"","export default \"__VITE_ASSET__a2f0ba11__\"","export default \"__VITE_ASSET__9510a760__\"","export default \"__VITE_ASSET__088b05e9__\"","export default \"__VITE_ASSET__a8811044__\"","export default \"__VITE_ASSET__104704b5__\""],"names":[],"mappings":";AAAA,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,aAAA;ACAf,MAAe,aAAA;ACAf,MAAe,aAAA;ACAf,MAAe,aAAA;ACAf,MAAe,eAAA;ACAf,MAAe,aAAA;;;;;;;;;;;;;;;"}
+{"version":3,"file":"assets.js","sources":["static/images/warning.gif","static/images/strat.svg","static/images/end.svg","static/images/bus.svg","static/logo.png","static/icon/tingtong.png","static/icon/audio.png","static/icon/video.png","static/icon/message.png","static/icon/logout.png","static/images/deviceunlogin.png"],"sourcesContent":["export default \"__VITE_ASSET__dba43bae__\"","export default \"__VITE_ASSET__393b1428__\"","export default \"__VITE_ASSET__7a909323__\"","export default \"__VITE_ASSET__257f5630__\"","export default \"__VITE_ASSET__0d1a51c4__\"","export default \"__VITE_ASSET__74cc0994__\"","export default \"__VITE_ASSET__a2f0ba11__\"","export default \"__VITE_ASSET__9510a760__\"","export default \"__VITE_ASSET__088b05e9__\"","export default \"__VITE_ASSET__a8811044__\"","export default \"__VITE_ASSET__104704b5__\""],"names":[],"mappings":";AAAA,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,eAAA;ACAf,MAAe,aAAA;ACAf,MAAe,aAAA;ACAf,MAAe,aAAA;ACAf,MAAe,eAAA;ACAf,MAAe,aAAA;;;;;;;;;;;;"}

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
unpackage/dist/dev/.sourcemap/mp-weixin/common/vendor.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 1
unpackage/dist/dev/.sourcemap/mp-weixin/components/icon/index.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-input/u-input.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-overlay/u-overlay.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-picker/u-picker.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-popup/u-popup.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-status-bar/u-status-bar.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
unpackage/dist/dev/.sourcemap/mp-weixin/node-modules/uview-plus/components/u-toolbar/u-toolbar.js.map


+ 2 - 8
unpackage/dist/dev/mp-weixin/common/assets.js

@@ -2,12 +2,9 @@
 const _imports_0$4 = "/static/images/warning.gif";
 const _imports_0$3 = "/static/images/strat.svg";
 const _imports_1$2 = "/static/images/end.svg";
-const _imports_2$2 = "/static/images/bus.svg";
+const _imports_2$1 = "/static/images/bus.svg";
 const _imports_0$2 = "/static/logo.png";
 const _imports_1$1 = "/static/icon/tingtong.png";
-const _imports_2$1 = "/static/icon/guangbo.png";
-const _imports_3$1 = "/static/icon/yinbo.png";
-const _imports_4 = "/static/icon/emjoy.png";
 const _imports_1 = "/static/icon/audio.png";
 const _imports_2 = "/static/icon/video.png";
 const _imports_3 = "/static/icon/message.png";
@@ -21,10 +18,7 @@ exports._imports_0$4 = _imports_0$2;
 exports._imports_1 = _imports_1$2;
 exports._imports_1$1 = _imports_1;
 exports._imports_1$2 = _imports_1$1;
-exports._imports_2 = _imports_2$2;
+exports._imports_2 = _imports_2$1;
 exports._imports_2$1 = _imports_2;
-exports._imports_2$2 = _imports_2$1;
 exports._imports_3 = _imports_3;
-exports._imports_3$1 = _imports_3$1;
-exports._imports_4 = _imports_4;
 //# sourceMappingURL=../../.sourcemap/mp-weixin/common/assets.js.map

+ 533 - 533
unpackage/dist/dev/mp-weixin/common/vendor.js

@@ -7191,7 +7191,7 @@ function isConsoleWritable() {
 function initRuntimeSocketService() {
   const hosts = "127.0.0.1,192.168.3.198";
   const port = "8090";
-  const id = "mp-weixin_OAFe8r";
+  const id = "mp-weixin_7L8wXr";
   const lazy = typeof swan !== "undefined";
   let restoreError = lazy ? () => {
   } : initOnError();
@@ -13681,6 +13681,105 @@ const props$c = defineMixin({
   props: {}
 });
 const props$b = defineMixin({
+  props: {
+    // 是否展示弹窗
+    show: {
+      type: Boolean,
+      default: () => props$f.popup.show
+    },
+    // 是否显示遮罩
+    overlay: {
+      type: Boolean,
+      default: () => props$f.popup.overlay
+    },
+    // 弹出的方向,可选值为 top bottom right left center
+    mode: {
+      type: String,
+      default: () => props$f.popup.mode
+    },
+    // 动画时长,单位ms
+    duration: {
+      type: [String, Number],
+      default: () => props$f.popup.duration
+    },
+    // 是否显示关闭图标
+    closeable: {
+      type: Boolean,
+      default: () => props$f.popup.closeable
+    },
+    // 自定义遮罩的样式
+    overlayStyle: {
+      type: [Object, String],
+      default: () => props$f.popup.overlayStyle
+    },
+    // 点击遮罩是否关闭弹窗
+    closeOnClickOverlay: {
+      type: Boolean,
+      default: () => props$f.popup.closeOnClickOverlay
+    },
+    // 层级
+    zIndex: {
+      type: [String, Number],
+      default: () => props$f.popup.zIndex
+    },
+    // 是否为iPhoneX留出底部安全距离
+    safeAreaInsetBottom: {
+      type: Boolean,
+      default: () => props$f.popup.safeAreaInsetBottom
+    },
+    // 是否留出顶部安全距离(状态栏高度)
+    safeAreaInsetTop: {
+      type: Boolean,
+      default: () => props$f.popup.safeAreaInsetTop
+    },
+    // 自定义关闭图标位置,top-left为左上角,top-right为右上角,bottom-left为左下角,bottom-right为右下角
+    closeIconPos: {
+      type: String,
+      default: () => props$f.popup.closeIconPos
+    },
+    // 是否显示圆角
+    round: {
+      type: [Boolean, String, Number],
+      default: () => props$f.popup.round
+    },
+    // mode=center,也即中部弹出时,是否使用缩放模式
+    zoom: {
+      type: Boolean,
+      default: () => props$f.popup.zoom
+    },
+    // 弹窗背景色,设置为transparent可去除白色背景
+    bgColor: {
+      type: String,
+      default: () => props$f.popup.bgColor
+    },
+    // 遮罩的透明度,0-1之间
+    overlayOpacity: {
+      type: [Number, String],
+      default: () => props$f.popup.overlayOpacity
+    },
+    // 是否页面内展示
+    pageInline: {
+      type: Boolean,
+      default: () => props$f.popup.pageInline
+    },
+    // 是否页开启手势滑动
+    touchable: {
+      type: Boolean,
+      default: () => props$f.popup.touchable
+    },
+    // 手势滑动最小高度
+    minHeight: {
+      type: [String],
+      default: () => props$f.popup.minHeight
+    },
+    // 手势滑动最大高度
+    maxHeight: {
+      type: [String],
+      default: () => props$f.popup.maxHeight
+    }
+  }
+});
+const props$a = defineMixin({
   props: {
     // 是否显示组件
     show: {
@@ -13739,10 +13838,10 @@ const props$b = defineMixin({
     }
   }
 });
-const props$a = defineMixin({
+const props$9 = defineMixin({
   props: {}
 });
-const props$9 = defineMixin({
+const props$8 = defineMixin({
   props: {
     // 是否显示input
     hasInput: {
@@ -14189,354 +14288,40 @@ var e = function() {
     return b(1e3 * t3);
   }, b.en = D[m], b.Ls = D, b.p = {}, b;
 }();
-const props$8 = defineMixin({
+const props$7 = defineMixin({
   props: {
-    // 绑定的值
-    modelValue: {
-      type: [String, Number],
-      default: () => props$f.input.value
-    },
-    // number-数字输入键盘,app-vue下可以输入浮点数,app-nvue和小程序平台下只能输入整数
-    // idcard-身份证输入键盘,微信、支付宝、百度、QQ小程序
-    // digit-带小数点的数字键盘,App的nvue页面、微信、支付宝、百度、头条、QQ小程序
-    // text-文本输入键盘
-    type: {
-      type: String,
-      default: () => props$f.input.type
-    },
-    // 如果 textarea 是在一个 position:fixed 的区域,需要显示指定属性 fixed 为 true,
-    // 兼容性:微信小程序、百度小程序、字节跳动小程序、QQ小程序
-    fixed: {
-      type: Boolean,
-      default: () => props$f.input.fixed
-    },
-    // 是否禁用输入框
-    disabled: {
-      type: Boolean,
-      default: () => props$f.input.disabled
-    },
-    // 禁用状态时的背景色
-    disabledColor: {
-      type: String,
-      default: () => props$f.input.disabledColor
-    },
-    // 是否显示清除控件
-    clearable: {
-      type: Boolean,
-      default: false
-    },
-    // 是否仅在聚焦时显示清除控件
-    onlyClearableOnFocused: {
-      type: Boolean,
-      default: true
-    },
-    // 是否密码类型
-    password: {
-      type: Boolean,
-      default: () => props$f.input.password
-    },
-    // 最大输入长度,设置为 -1 的时候不限制最大长度
-    maxlength: {
-      type: [String, Number],
-      default: () => props$f.input.maxlength
-    },
-    // 	输入框为空时的占位符
-    placeholder: {
-      type: String,
-      default: () => props$f.input.placeholder
-    },
-    // 指定placeholder的样式类,注意页面或组件的style中写了scoped时,需要在类名前写/deep/
-    placeholderClass: {
-      type: String,
-      default: () => props$f.input.placeholderClass
-    },
-    // 指定placeholder的样式
-    placeholderStyle: {
-      type: [String, Object],
-      default: () => props$f.input.placeholderStyle
-    },
-    // 是否显示输入字数统计,只在 type ="text"或type ="textarea"时有效
-    showWordLimit: {
-      type: Boolean,
-      default: () => props$f.input.showWordLimit
-    },
-    // 设置右下角按钮的文字,有效值:send|search|next|go|done,兼容性详见uni-app文档
-    // https://uniapp.dcloud.io/component/input
-    // https://uniapp.dcloud.io/component/textarea
-    confirmType: {
-      type: String,
-      default: () => props$f.input.confirmType
-    },
-    // 点击键盘右下角按钮时是否保持键盘不收起,H5无效
-    confirmHold: {
-      type: Boolean,
-      default: () => props$f.input.confirmHold
-    },
-    // focus时,点击页面的时候不收起键盘,微信小程序有效
-    holdKeyboard: {
-      type: Boolean,
-      default: () => props$f.input.holdKeyboard
-    },
-    // 自动获取焦点
-    // 在 H5 平台能否聚焦以及软键盘是否跟随弹出,取决于当前浏览器本身的实现。nvue 页面不支持,需使用组件的 focus()、blur() 方法控制焦点
-    focus: {
-      type: Boolean,
-      default: () => props$f.input.focus
-    },
-    // 键盘收起时,是否自动失去焦点,目前仅App3.0.0+有效
-    autoBlur: {
-      type: Boolean,
-      default: () => props$f.input.autoBlur
-    },
-    // 是否去掉 iOS 下的默认内边距,仅微信小程序,且type=textarea时有效
-    disableDefaultPadding: {
+    // 是否显示遮罩
+    show: {
       type: Boolean,
-      default: () => props$f.input.disableDefaultPadding
-    },
-    // 指定focus时光标的位置
-    cursor: {
-      type: [String, Number],
-      default: () => props$f.input.cursor
+      default: () => props$f.overlay.show
     },
-    // 输入框聚焦时底部与键盘的距离
-    cursorSpacing: {
+    // 层级z-index
+    zIndex: {
       type: [String, Number],
-      default: () => props$f.input.cursorSpacing
+      default: () => props$f.overlay.zIndex
     },
-    // 光标起始位置,自动聚集时有效,需与selection-end搭配使用
-    selectionStart: {
+    // 遮罩的过渡时间,单位为ms
+    duration: {
       type: [String, Number],
-      default: () => props$f.input.selectionStart
+      default: () => props$f.overlay.duration
     },
-    // 光标结束位置,自动聚集时有效,需与selection-start搭配使用
-    selectionEnd: {
+    // 不透明度值,当做rgba的第四个参数
+    opacity: {
       type: [String, Number],
-      default: () => props$f.input.selectionEnd
-    },
-    // 键盘弹起时,是否自动上推页面
-    adjustPosition: {
-      type: Boolean,
-      default: () => props$f.input.adjustPosition
-    },
-    // 输入框内容对齐方式,可选值为:left|center|right
-    inputAlign: {
+      default: () => props$f.overlay.opacity
+    }
+  }
+});
+const props$6 = defineMixin({
+  props: {
+    bgColor: {
       type: String,
-      default: () => props$f.input.inputAlign
-    },
-    // 输入框字体的大小
-    fontSize: {
-      type: [String, Number],
-      default: () => props$f.input.fontSize
+      default: () => props$f.statusBar.bgColor
     },
-    // 输入框字体颜色
-    color: {
-      type: String,
-      default: () => props$f.input.color
-    },
-    // 输入框前置图标
-    prefixIcon: {
-      type: String,
-      default: () => props$f.input.prefixIcon
-    },
-    // 前置图标样式,对象或字符串
-    prefixIconStyle: {
-      type: [String, Object],
-      default: () => props$f.input.prefixIconStyle
-    },
-    // 输入框后置图标
-    suffixIcon: {
-      type: String,
-      default: () => props$f.input.suffixIcon
-    },
-    // 后置图标样式,对象或字符串
-    suffixIconStyle: {
-      type: [String, Object],
-      default: () => props$f.input.suffixIconStyle
-    },
-    // 边框类型,surround-四周边框,bottom-底部边框,none-无边框
-    border: {
-      type: String,
-      default: () => props$f.input.border
-    },
-    // 是否只读,与disabled不同之处在于disabled会置灰组件,而readonly则不会
-    readonly: {
-      type: Boolean,
-      default: () => props$f.input.readonly
-    },
-    // 输入框形状,circle-圆形,square-方形
-    shape: {
-      type: String,
-      default: () => props$f.input.shape
-    },
-    // 用于处理或者过滤输入框内容的方法
-    formatter: {
-      type: [Function, null],
-      default: () => props$f.input.formatter
-    },
-    // 是否忽略组件内对文本合成系统事件的处理
-    ignoreCompositionEvent: {
-      type: Boolean,
-      default: true
-    },
-    // 光标颜色
-    cursorColor: {
-      type: String,
-      default: () => props$f.input.cursorColor
-    },
-    // 密码类型可见性切换
-    passwordVisibilityToggle: {
-      type: Boolean,
-      default: () => props$f.input.passwordVisibilityToggle
-    }
-  }
-});
-const props$7 = defineMixin({
-  props: {
-    modelValue: {
-      type: Array,
-      default: () => []
-    },
-    hasInput: {
-      type: Boolean,
-      default: false
-    },
-    inputProps: {
-      type: Object,
-      default: () => {
-        return {};
-      }
-    },
-    disabled: {
-      type: Boolean,
-      default: () => props$f.picker.disabled
-    },
-    disabledColor: {
-      type: String,
-      default: () => props$f.picker.disabledColor
-    },
-    placeholder: {
-      type: String,
-      default: () => props$f.picker.placeholder
-    },
-    // 是否展示picker弹窗
-    show: {
-      type: Boolean,
-      default: () => props$f.picker.show
-    },
-    // 弹出的方向,可选值为 top bottom right left center
-    popupMode: {
-      type: String,
-      default: () => props$f.picker.popupMode
-    },
-    // 是否展示顶部的操作栏
-    showToolbar: {
-      type: Boolean,
-      default: () => props$f.picker.showToolbar
-    },
-    // 顶部标题
-    title: {
-      type: String,
-      default: () => props$f.picker.title
-    },
-    // 对象数组,设置每一列的数据
-    columns: {
-      type: Array,
-      default: () => props$f.picker.columns
-    },
-    // 是否显示加载中状态
-    loading: {
-      type: Boolean,
-      default: () => props$f.picker.loading
-    },
-    // 各列中,单个选项的高度
-    itemHeight: {
-      type: [String, Number],
-      default: () => props$f.picker.itemHeight
-    },
-    // 取消按钮的文字
-    cancelText: {
-      type: String,
-      default: () => props$f.picker.cancelText
-    },
-    // 确认按钮的文字
-    confirmText: {
-      type: String,
-      default: () => props$f.picker.confirmText
-    },
-    // 取消按钮的颜色
-    cancelColor: {
-      type: String,
-      default: () => props$f.picker.cancelColor
-    },
-    // 确认按钮的颜色
-    confirmColor: {
-      type: String,
-      default: () => props$f.picker.confirmColor
-    },
-    // 每列中可见选项的数量
-    visibleItemCount: {
-      type: [String, Number],
-      default: () => props$f.picker.visibleItemCount
-    },
-    // 选项对象中,需要展示的属性键名
-    keyName: {
-      type: String,
-      default: () => props$f.picker.keyName
-    },
-    // 选项对象中,需要获取的属性值键名
-    valueName: {
-      type: String,
-      default: () => props$f.picker.valueName
-    },
-    // 是否允许点击遮罩关闭选择器
-    closeOnClickOverlay: {
-      type: Boolean,
-      default: () => props$f.picker.closeOnClickOverlay
-    },
-    // 各列的默认索引
-    defaultIndex: {
-      type: Array,
-      default: () => props$f.picker.defaultIndex
-    },
-    // 是否在手指松开时立即触发 change 事件。若不开启则会在滚动动画结束后触发 change 事件,只在微信2.21.1及以上有效
-    immediateChange: {
-      type: Boolean,
-      default: () => props$f.picker.immediateChange
-    },
-    // 工具栏右侧插槽是否开启
-    toolbarRightSlot: {
-      type: Boolean,
-      default: false
-    },
-    // 层级
-    zIndex: {
-      type: [String, Number],
-      default: () => props$f.picker.zIndex
-    },
-    // 弹窗背景色,设置为transparent可去除白色背景
-    bgColor: {
-      type: String,
-      default: () => props$f.picker.bgColor
-    },
-    // 是否显示圆角
-    round: {
-      type: [Boolean, String, Number],
-      default: () => props$f.picker.round
-    },
-    // 动画时长,单位ms
-    duration: {
-      type: [String, Number],
-      default: () => props$f.picker.duration
-    },
-    // 遮罩的透明度,0-1之间
-    overlayOpacity: {
-      type: [Number, String],
-      default: () => props$f.picker.overlayOpacity
-    },
-    // 是否页面内展示
-    pageInline: {
-      type: Boolean,
-      default: () => props$f.picker.pageInline
+    // 状态栏获取得高度
+    height: {
+      type: Number,
+      default: () => props$f.statusBar.height
     }
   }
 });
@@ -14754,7 +14539,7 @@ const icons = {
   "uicon-zh": "",
   "uicon-en": ""
 };
-const props$6 = defineMixin({
+const props$5 = defineMixin({
   props: {
     // 图标类名
     name: {
@@ -14843,205 +14628,30 @@ const props$6 = defineMixin({
     }
   }
 });
-const props$5 = defineMixin({
+const props$4 = defineMixin({
+  props: {}
+});
+const props$3 = defineMixin({
   props: {
-    // 是否展示工具条
+    // 是否展示组件
     show: {
       type: Boolean,
-      default: () => props$f.toolbar.show
-    },
-    // 取消按钮的文字
-    cancelText: {
-      type: String,
-      default: () => props$f.toolbar.cancelText
-    },
-    // 确认按钮的文字
-    confirmText: {
-      type: String,
-      default: () => props$f.toolbar.confirmText
+      default: () => props$f.transition.show
     },
-    // 取消按钮的颜色
-    cancelColor: {
+    // 使用的动画模式
+    mode: {
       type: String,
-      default: () => props$f.toolbar.cancelColor
+      default: () => props$f.transition.mode
     },
-    // 确认按钮的颜色
-    confirmColor: {
-      type: String,
-      default: () => props$f.toolbar.confirmColor
+    // 动画的执行时间,单位ms
+    duration: {
+      type: [String, Number],
+      default: () => props$f.transition.duration
     },
-    // 标题文字
-    title: {
+    // 使用的动画过渡函数
+    timingFunction: {
       type: String,
-      default: () => props$f.toolbar.title
-    },
-    // 开启右侧插槽
-    rightSlot: {
-      type: Boolean,
-      default: false
-    }
-  }
-});
-const props$4 = defineMixin({
-  props: {
-    // 是否展示弹窗
-    show: {
-      type: Boolean,
-      default: () => props$f.popup.show
-    },
-    // 是否显示遮罩
-    overlay: {
-      type: Boolean,
-      default: () => props$f.popup.overlay
-    },
-    // 弹出的方向,可选值为 top bottom right left center
-    mode: {
-      type: String,
-      default: () => props$f.popup.mode
-    },
-    // 动画时长,单位ms
-    duration: {
-      type: [String, Number],
-      default: () => props$f.popup.duration
-    },
-    // 是否显示关闭图标
-    closeable: {
-      type: Boolean,
-      default: () => props$f.popup.closeable
-    },
-    // 自定义遮罩的样式
-    overlayStyle: {
-      type: [Object, String],
-      default: () => props$f.popup.overlayStyle
-    },
-    // 点击遮罩是否关闭弹窗
-    closeOnClickOverlay: {
-      type: Boolean,
-      default: () => props$f.popup.closeOnClickOverlay
-    },
-    // 层级
-    zIndex: {
-      type: [String, Number],
-      default: () => props$f.popup.zIndex
-    },
-    // 是否为iPhoneX留出底部安全距离
-    safeAreaInsetBottom: {
-      type: Boolean,
-      default: () => props$f.popup.safeAreaInsetBottom
-    },
-    // 是否留出顶部安全距离(状态栏高度)
-    safeAreaInsetTop: {
-      type: Boolean,
-      default: () => props$f.popup.safeAreaInsetTop
-    },
-    // 自定义关闭图标位置,top-left为左上角,top-right为右上角,bottom-left为左下角,bottom-right为右下角
-    closeIconPos: {
-      type: String,
-      default: () => props$f.popup.closeIconPos
-    },
-    // 是否显示圆角
-    round: {
-      type: [Boolean, String, Number],
-      default: () => props$f.popup.round
-    },
-    // mode=center,也即中部弹出时,是否使用缩放模式
-    zoom: {
-      type: Boolean,
-      default: () => props$f.popup.zoom
-    },
-    // 弹窗背景色,设置为transparent可去除白色背景
-    bgColor: {
-      type: String,
-      default: () => props$f.popup.bgColor
-    },
-    // 遮罩的透明度,0-1之间
-    overlayOpacity: {
-      type: [Number, String],
-      default: () => props$f.popup.overlayOpacity
-    },
-    // 是否页面内展示
-    pageInline: {
-      type: Boolean,
-      default: () => props$f.popup.pageInline
-    },
-    // 是否页开启手势滑动
-    touchable: {
-      type: Boolean,
-      default: () => props$f.popup.touchable
-    },
-    // 手势滑动最小高度
-    minHeight: {
-      type: [String],
-      default: () => props$f.popup.minHeight
-    },
-    // 手势滑动最大高度
-    maxHeight: {
-      type: [String],
-      default: () => props$f.popup.maxHeight
-    }
-  }
-});
-const props$3 = defineMixin({
-  props: {
-    // 是否显示遮罩
-    show: {
-      type: Boolean,
-      default: () => props$f.overlay.show
-    },
-    // 层级z-index
-    zIndex: {
-      type: [String, Number],
-      default: () => props$f.overlay.zIndex
-    },
-    // 遮罩的过渡时间,单位为ms
-    duration: {
-      type: [String, Number],
-      default: () => props$f.overlay.duration
-    },
-    // 不透明度值,当做rgba的第四个参数
-    opacity: {
-      type: [String, Number],
-      default: () => props$f.overlay.opacity
-    }
-  }
-});
-const props$2 = defineMixin({
-  props: {
-    bgColor: {
-      type: String,
-      default: () => props$f.statusBar.bgColor
-    },
-    // 状态栏获取得高度
-    height: {
-      type: Number,
-      default: () => props$f.statusBar.height
-    }
-  }
-});
-const props$1 = defineMixin({
-  props: {}
-});
-const props = defineMixin({
-  props: {
-    // 是否展示组件
-    show: {
-      type: Boolean,
-      default: () => props$f.transition.show
-    },
-    // 使用的动画模式
-    mode: {
-      type: String,
-      default: () => props$f.transition.mode
-    },
-    // 动画的执行时间,单位ms
-    duration: {
-      type: [String, Number],
-      default: () => props$f.transition.duration
-    },
-    // 使用的动画过渡函数
-    timingFunction: {
-      type: String,
-      default: () => props$f.transition.timingFunction
+      default: () => props$f.transition.timingFunction
     }
   }
 });
@@ -15103,6 +14713,396 @@ const transitionMixin = {
     }
   }
 };
+const props$2 = defineMixin({
+  props: {
+    // 绑定的值
+    modelValue: {
+      type: [String, Number],
+      default: () => props$f.input.value
+    },
+    // number-数字输入键盘,app-vue下可以输入浮点数,app-nvue和小程序平台下只能输入整数
+    // idcard-身份证输入键盘,微信、支付宝、百度、QQ小程序
+    // digit-带小数点的数字键盘,App的nvue页面、微信、支付宝、百度、头条、QQ小程序
+    // text-文本输入键盘
+    type: {
+      type: String,
+      default: () => props$f.input.type
+    },
+    // 如果 textarea 是在一个 position:fixed 的区域,需要显示指定属性 fixed 为 true,
+    // 兼容性:微信小程序、百度小程序、字节跳动小程序、QQ小程序
+    fixed: {
+      type: Boolean,
+      default: () => props$f.input.fixed
+    },
+    // 是否禁用输入框
+    disabled: {
+      type: Boolean,
+      default: () => props$f.input.disabled
+    },
+    // 禁用状态时的背景色
+    disabledColor: {
+      type: String,
+      default: () => props$f.input.disabledColor
+    },
+    // 是否显示清除控件
+    clearable: {
+      type: Boolean,
+      default: false
+    },
+    // 是否仅在聚焦时显示清除控件
+    onlyClearableOnFocused: {
+      type: Boolean,
+      default: true
+    },
+    // 是否密码类型
+    password: {
+      type: Boolean,
+      default: () => props$f.input.password
+    },
+    // 最大输入长度,设置为 -1 的时候不限制最大长度
+    maxlength: {
+      type: [String, Number],
+      default: () => props$f.input.maxlength
+    },
+    // 	输入框为空时的占位符
+    placeholder: {
+      type: String,
+      default: () => props$f.input.placeholder
+    },
+    // 指定placeholder的样式类,注意页面或组件的style中写了scoped时,需要在类名前写/deep/
+    placeholderClass: {
+      type: String,
+      default: () => props$f.input.placeholderClass
+    },
+    // 指定placeholder的样式
+    placeholderStyle: {
+      type: [String, Object],
+      default: () => props$f.input.placeholderStyle
+    },
+    // 是否显示输入字数统计,只在 type ="text"或type ="textarea"时有效
+    showWordLimit: {
+      type: Boolean,
+      default: () => props$f.input.showWordLimit
+    },
+    // 设置右下角按钮的文字,有效值:send|search|next|go|done,兼容性详见uni-app文档
+    // https://uniapp.dcloud.io/component/input
+    // https://uniapp.dcloud.io/component/textarea
+    confirmType: {
+      type: String,
+      default: () => props$f.input.confirmType
+    },
+    // 点击键盘右下角按钮时是否保持键盘不收起,H5无效
+    confirmHold: {
+      type: Boolean,
+      default: () => props$f.input.confirmHold
+    },
+    // focus时,点击页面的时候不收起键盘,微信小程序有效
+    holdKeyboard: {
+      type: Boolean,
+      default: () => props$f.input.holdKeyboard
+    },
+    // 自动获取焦点
+    // 在 H5 平台能否聚焦以及软键盘是否跟随弹出,取决于当前浏览器本身的实现。nvue 页面不支持,需使用组件的 focus()、blur() 方法控制焦点
+    focus: {
+      type: Boolean,
+      default: () => props$f.input.focus
+    },
+    // 键盘收起时,是否自动失去焦点,目前仅App3.0.0+有效
+    autoBlur: {
+      type: Boolean,
+      default: () => props$f.input.autoBlur
+    },
+    // 是否去掉 iOS 下的默认内边距,仅微信小程序,且type=textarea时有效
+    disableDefaultPadding: {
+      type: Boolean,
+      default: () => props$f.input.disableDefaultPadding
+    },
+    // 指定focus时光标的位置
+    cursor: {
+      type: [String, Number],
+      default: () => props$f.input.cursor
+    },
+    // 输入框聚焦时底部与键盘的距离
+    cursorSpacing: {
+      type: [String, Number],
+      default: () => props$f.input.cursorSpacing
+    },
+    // 光标起始位置,自动聚集时有效,需与selection-end搭配使用
+    selectionStart: {
+      type: [String, Number],
+      default: () => props$f.input.selectionStart
+    },
+    // 光标结束位置,自动聚集时有效,需与selection-start搭配使用
+    selectionEnd: {
+      type: [String, Number],
+      default: () => props$f.input.selectionEnd
+    },
+    // 键盘弹起时,是否自动上推页面
+    adjustPosition: {
+      type: Boolean,
+      default: () => props$f.input.adjustPosition
+    },
+    // 输入框内容对齐方式,可选值为:left|center|right
+    inputAlign: {
+      type: String,
+      default: () => props$f.input.inputAlign
+    },
+    // 输入框字体的大小
+    fontSize: {
+      type: [String, Number],
+      default: () => props$f.input.fontSize
+    },
+    // 输入框字体颜色
+    color: {
+      type: String,
+      default: () => props$f.input.color
+    },
+    // 输入框前置图标
+    prefixIcon: {
+      type: String,
+      default: () => props$f.input.prefixIcon
+    },
+    // 前置图标样式,对象或字符串
+    prefixIconStyle: {
+      type: [String, Object],
+      default: () => props$f.input.prefixIconStyle
+    },
+    // 输入框后置图标
+    suffixIcon: {
+      type: String,
+      default: () => props$f.input.suffixIcon
+    },
+    // 后置图标样式,对象或字符串
+    suffixIconStyle: {
+      type: [String, Object],
+      default: () => props$f.input.suffixIconStyle
+    },
+    // 边框类型,surround-四周边框,bottom-底部边框,none-无边框
+    border: {
+      type: String,
+      default: () => props$f.input.border
+    },
+    // 是否只读,与disabled不同之处在于disabled会置灰组件,而readonly则不会
+    readonly: {
+      type: Boolean,
+      default: () => props$f.input.readonly
+    },
+    // 输入框形状,circle-圆形,square-方形
+    shape: {
+      type: String,
+      default: () => props$f.input.shape
+    },
+    // 用于处理或者过滤输入框内容的方法
+    formatter: {
+      type: [Function, null],
+      default: () => props$f.input.formatter
+    },
+    // 是否忽略组件内对文本合成系统事件的处理
+    ignoreCompositionEvent: {
+      type: Boolean,
+      default: true
+    },
+    // 光标颜色
+    cursorColor: {
+      type: String,
+      default: () => props$f.input.cursorColor
+    },
+    // 密码类型可见性切换
+    passwordVisibilityToggle: {
+      type: Boolean,
+      default: () => props$f.input.passwordVisibilityToggle
+    }
+  }
+});
+const props$1 = defineMixin({
+  props: {
+    modelValue: {
+      type: Array,
+      default: () => []
+    },
+    hasInput: {
+      type: Boolean,
+      default: false
+    },
+    inputProps: {
+      type: Object,
+      default: () => {
+        return {};
+      }
+    },
+    disabled: {
+      type: Boolean,
+      default: () => props$f.picker.disabled
+    },
+    disabledColor: {
+      type: String,
+      default: () => props$f.picker.disabledColor
+    },
+    placeholder: {
+      type: String,
+      default: () => props$f.picker.placeholder
+    },
+    // 是否展示picker弹窗
+    show: {
+      type: Boolean,
+      default: () => props$f.picker.show
+    },
+    // 弹出的方向,可选值为 top bottom right left center
+    popupMode: {
+      type: String,
+      default: () => props$f.picker.popupMode
+    },
+    // 是否展示顶部的操作栏
+    showToolbar: {
+      type: Boolean,
+      default: () => props$f.picker.showToolbar
+    },
+    // 顶部标题
+    title: {
+      type: String,
+      default: () => props$f.picker.title
+    },
+    // 对象数组,设置每一列的数据
+    columns: {
+      type: Array,
+      default: () => props$f.picker.columns
+    },
+    // 是否显示加载中状态
+    loading: {
+      type: Boolean,
+      default: () => props$f.picker.loading
+    },
+    // 各列中,单个选项的高度
+    itemHeight: {
+      type: [String, Number],
+      default: () => props$f.picker.itemHeight
+    },
+    // 取消按钮的文字
+    cancelText: {
+      type: String,
+      default: () => props$f.picker.cancelText
+    },
+    // 确认按钮的文字
+    confirmText: {
+      type: String,
+      default: () => props$f.picker.confirmText
+    },
+    // 取消按钮的颜色
+    cancelColor: {
+      type: String,
+      default: () => props$f.picker.cancelColor
+    },
+    // 确认按钮的颜色
+    confirmColor: {
+      type: String,
+      default: () => props$f.picker.confirmColor
+    },
+    // 每列中可见选项的数量
+    visibleItemCount: {
+      type: [String, Number],
+      default: () => props$f.picker.visibleItemCount
+    },
+    // 选项对象中,需要展示的属性键名
+    keyName: {
+      type: String,
+      default: () => props$f.picker.keyName
+    },
+    // 选项对象中,需要获取的属性值键名
+    valueName: {
+      type: String,
+      default: () => props$f.picker.valueName
+    },
+    // 是否允许点击遮罩关闭选择器
+    closeOnClickOverlay: {
+      type: Boolean,
+      default: () => props$f.picker.closeOnClickOverlay
+    },
+    // 各列的默认索引
+    defaultIndex: {
+      type: Array,
+      default: () => props$f.picker.defaultIndex
+    },
+    // 是否在手指松开时立即触发 change 事件。若不开启则会在滚动动画结束后触发 change 事件,只在微信2.21.1及以上有效
+    immediateChange: {
+      type: Boolean,
+      default: () => props$f.picker.immediateChange
+    },
+    // 工具栏右侧插槽是否开启
+    toolbarRightSlot: {
+      type: Boolean,
+      default: false
+    },
+    // 层级
+    zIndex: {
+      type: [String, Number],
+      default: () => props$f.picker.zIndex
+    },
+    // 弹窗背景色,设置为transparent可去除白色背景
+    bgColor: {
+      type: String,
+      default: () => props$f.picker.bgColor
+    },
+    // 是否显示圆角
+    round: {
+      type: [Boolean, String, Number],
+      default: () => props$f.picker.round
+    },
+    // 动画时长,单位ms
+    duration: {
+      type: [String, Number],
+      default: () => props$f.picker.duration
+    },
+    // 遮罩的透明度,0-1之间
+    overlayOpacity: {
+      type: [Number, String],
+      default: () => props$f.picker.overlayOpacity
+    },
+    // 是否页面内展示
+    pageInline: {
+      type: Boolean,
+      default: () => props$f.picker.pageInline
+    }
+  }
+});
+const props = defineMixin({
+  props: {
+    // 是否展示工具条
+    show: {
+      type: Boolean,
+      default: () => props$f.toolbar.show
+    },
+    // 取消按钮的文字
+    cancelText: {
+      type: String,
+      default: () => props$f.toolbar.cancelText
+    },
+    // 确认按钮的文字
+    confirmText: {
+      type: String,
+      default: () => props$f.toolbar.confirmText
+    },
+    // 取消按钮的颜色
+    cancelColor: {
+      type: String,
+      default: () => props$f.toolbar.cancelColor
+    },
+    // 确认按钮的颜色
+    confirmColor: {
+      type: String,
+      default: () => props$f.toolbar.confirmColor
+    },
+    // 标题文字
+    title: {
+      type: String,
+      default: () => props$f.toolbar.title
+    },
+    // 开启右侧插槽
+    rightSlot: {
+      type: Boolean,
+      default: false
+    }
+  }
+});
 exports.$parent = $parent;
 exports._export_sfc = _export_sfc;
 exports.addStyle = addStyle;

+ 19 - 5
unpackage/dist/dev/mp-weixin/components/icon/index.js

@@ -8,6 +8,12 @@ const _sfc_main = {
       type: String,
       required: true
     },
+    // 图标库:legacy(旧库)/base(基础库)/biz(业务库)
+    // 不传默认使用旧库,兼容现有代码
+    lib: {
+      type: String,
+      default: "legacy"
+    },
     // 图标颜色
     color: {
       type: String,
@@ -23,6 +29,13 @@ const _sfc_main = {
   setup(__props, { emit: __emit }) {
     const props = __props;
     const emit = __emit;
+    const fontClass = common_vendor.computed(() => {
+      if (props.lib === "base")
+        return "icon-base";
+      if (props.lib === "biz")
+        return "icon-biz";
+      return "iconfont";
+    });
     const iconClass = common_vendor.computed(() => {
       return props.name;
     });
@@ -31,11 +44,12 @@ const _sfc_main = {
     };
     return (_ctx, _cache) => {
       return {
-        a: common_vendor.n(iconClass.value),
-        b: __props.color,
-        c: __props.size + "rpx",
-        d: common_vendor.o(handleClick),
-        e: common_vendor.gei(_ctx, "")
+        a: common_vendor.n(fontClass.value),
+        b: common_vendor.n(iconClass.value),
+        c: __props.color,
+        d: __props.size + "rpx",
+        e: common_vendor.o(handleClick),
+        f: common_vendor.gei(_ctx, "")
       };
     };
   }

+ 1 - 1
unpackage/dist/dev/mp-weixin/components/icon/index.wxml

@@ -1 +1 @@
-<text class="{{['iconfont', a, virtualHostClass]}}" style="{{'color:' + b + ';' + ('font-size:' + c) + ';' + ('vertical-align:' + 'middle') + ';' + (virtualHostStyle || '')}}" bindtap="{{d}}" hidden="{{virtualHostHidden || false}}" id="{{e}}"></text>
+<text class="{{[a, b, virtualHostClass]}}" style="{{'color:' + c + ';' + ('font-size:' + d) + ';' + ('vertical-align:' + 'middle') + ';' + (virtualHostStyle || '')}}" bindtap="{{e}}" hidden="{{virtualHostHidden || false}}" id="{{f}}"></text>

Разница между файлами не показана из-за своего большого размера
+ 4 - 5
unpackage/dist/dev/mp-weixin/components/icon/index.wxss


+ 1 - 1
unpackage/dist/dev/mp-weixin/pages/my/index.wxml

@@ -1 +1 @@
-<view class="{{[virtualHostClass]}}" style="{{virtualHostStyle}}" hidden="{{virtualHostHidden || false}}" id="{{q}}"><swiper wx:if="{{a}}" class="{{['student-swiper', d]}}" indicator-dots="{{e}}" indicator-color="#ffffff" indicator-active-color="#666666" previous-margin="30rpx" next-margin="30rpx" bindchange="{{f}}"><swiper-item wx:for="{{b}}" wx:for-item="student" wx:key="g" bindtap="{{student.h}}"><view class="student-card"><view class="student-card-left"><image class="student-avatar" src="{{student.a}}"></image><view class="student-info"><text class="student-name">{{student.b}}</text><text class="student-class">{{student.c}}</text></view></view><view wx:if="{{c}}" catchtap="{{student.f}}" class="scan-btn"><icon wx:if="{{student.e}}" u-i="{{student.d}}" bind:__l="__l" u-p="{{student.e}}"/><text>扫一扫</text></view></view></swiper-item></swiper><view wx:if="{{g}}" class="container"><view class="function-list"><view class="function-item" bindtap="{{i}}"><view class="function-icon recharge-icon"><icon wx:if="{{h}}" u-i="76d75c10-1" bind:__l="__l" u-p="{{h}}"/></view><text class="function-name">充值</text></view><view wx:for="{{j}}" wx:for-item="item" wx:key="c" class="function-item" bindtap="{{item.d}}"><view class="function-icon"><image src="{{item.a}}" mode="aspectFill"></image></view><text class="function-name">{{item.b}}</text></view></view></view><view wx:else class="unlogin"><image src="{{k}}" mode="aspectFit"/></view><login-confirm-modal wx:if="{{p}}" class="r" virtualHostClass="r" u-r="loginConfirmModalRef" bindconfirm="{{m}}" bindcancel="{{n}}" u-i="76d75c10-2" bind:__l="__l" bindupdateVisible="{{o}}" u-p="{{p}}"/></view>
+<view class="{{[virtualHostClass]}}" style="{{virtualHostStyle}}" hidden="{{virtualHostHidden || false}}" id="{{s}}"><swiper wx:if="{{a}}" class="{{['student-swiper', d]}}" indicator-dots="{{e}}" indicator-color="#ffffff" indicator-active-color="#666666" previous-margin="30rpx" next-margin="30rpx" bindchange="{{f}}"><swiper-item wx:for="{{b}}" wx:for-item="student" wx:key="g" bindtap="{{student.h}}"><view class="student-card"><view class="student-card-left"><image class="student-avatar" src="{{student.a}}"></image><view class="student-info"><text class="student-name">{{student.b}}</text><text class="student-class">{{student.c}}</text></view></view><view wx:if="{{c}}" catchtap="{{student.f}}" class="scan-btn"><icon wx:if="{{student.e}}" u-i="{{student.d}}" bind:__l="__l" u-p="{{student.e}}"/><text>扫一扫</text></view></view></swiper-item></swiper><view wx:if="{{g}}" class="container"><view class="function-list"><view class="function-item" bindtap="{{i}}"><view class="function-icon recharge-icon"><icon wx:if="{{h}}" u-i="76d75c10-1" bind:__l="__l" u-p="{{h}}"/></view><text class="function-name">订阅</text></view><view class="function-item" bindtap="{{k}}"><view class="function-icon"><icon wx:if="{{j}}" u-i="76d75c10-2" bind:__l="__l" u-p="{{j}}"/></view><text class="function-name">呼叫留言</text></view><view wx:for="{{l}}" wx:for-item="item" wx:key="c" class="function-item" bindtap="{{item.d}}"><view class="function-icon"><image src="{{item.a}}" mode="aspectFill"></image></view><text class="function-name">{{item.b}}</text></view></view></view><view wx:else class="unlogin"><image src="{{m}}" mode="aspectFit"/></view><login-confirm-modal wx:if="{{r}}" class="r" virtualHostClass="r" u-r="loginConfirmModalRef" bindconfirm="{{o}}" bindcancel="{{p}}" u-i="76d75c10-3" bind:__l="__l" bindupdateVisible="{{q}}" u-p="{{r}}"/></view>

+ 4 - 1
unpackage/dist/dev/mp-weixin/pages/parent/message.json

@@ -1,4 +1,7 @@
 {
   "navigationBarTitleText": "消息",
-  "usingComponents": {}
+  "usingComponents": {
+    "ss-onoff-button": "../../components/SsOnoffButton/index",
+    "icon": "../../components/icon/index"
+  }
 }

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
unpackage/dist/dev/mp-weixin/pages/parent/message.wxml


+ 452 - 146
unpackage/dist/dev/mp-weixin/pages/parent/message.wxss

@@ -1,111 +1,28 @@
-.heng.data-v-61746e12 {
-  min-height: 100vh;
+.message-page.data-v-61746e12 {
+  height: 100vh;
   background: #f5f5f5;
   display: flex;
   flex-direction: column;
-}
-.heng .message-list.data-v-61746e12 {
-  flex: 1;
-  padding: 5rpx;
-  background: #fff;
-}
-.heng .message-item.data-v-61746e12 {
-  display: flex;
-  align-items: flex-start;
-  margin: 15rpx 20rpx;
-}
-.heng .avatar.data-v-61746e12 {
-  width: 30rpx;
-  height: 30rpx;
-  border-radius: 2rpx;
-  flex-shrink: 0;
-}
-.heng .voice-message.data-v-61746e12 {
-  position: relative;
-  display: flex;
-  align-items: flex-end;
-}
-.heng .voice-duration.data-v-61746e12 {
-  background: #a4b3d4;
-  padding: 6rpx;
-  border-radius: 4rpx;
-  color: #fff;
-  font-size: 12rpx;
-  position: relative;
-  min-width: 50rpx;
-  margin: 0 10rpx;
-  display: flex;
-  align-items: center;
-}
-.heng .voice-duration image.data-v-61746e12 {
-  width: 18rpx;
-  height: 18rpx;
-}
-.heng .voice-duration text.data-v-61746e12 {
-  margin: 0 5rpx;
-}
-.heng .right.data-v-61746e12 {
-  flex-direction: row-reverse;
-}
-.heng .right .avatar.data-v-61746e12 {
-  border-radius: 50%;
-}
-.heng .right .voice-message.data-v-61746e12 {
-  flex-direction: row-reverse;
-}
-.heng .right .voice-duration.data-v-61746e12 {
-  background: #e9e9e9;
-  color: #5e5e5e;
-}
-.heng .time.data-v-61746e12 {
-  font-size: 12rpx;
-  color: #999;
-  display: block;
-}
-.heng .recording-dot.data-v-61746e12 {
-  width: 5rpx;
-  height: 5rpx;
-  background: #eb6100;
-  border-radius: 50%;
-  position: absolute;
-  right: 3rpx;
-  top: 3rpx;
-}
-.heng .footer.data-v-61746e12 {
-  padding: 10rpx 35rpx;
-  background: #f5f5f5;
-  display: flex;
-  align-items: center;
-  border-top: 1rpx solid #eee;
-  font-size: 14rpx;
-}
-.heng .input-box.data-v-61746e12 {
-  flex: 1;
-  background: #ffffff;
-  height: 30rpx;
-  line-height: 30rpx;
-  text-align: center;
-  border-radius: 4rpx;
-  margin-right: 10rpx;
-  color: #666;
-  border: 1rpx solid #eee;
-}
-.heng .emoji-btn image.data-v-61746e12 {
-  width: 20rpx;
-  height: 20rpx;
-}
-.shu.data-v-61746e12 {
-  min-height: 100vh;
-  background: #f5f5f5;
-  display: flex;
-  flex-direction: column;
-}
-.shu .nav-header.data-v-61746e12 {
+  overflow: hidden;
+  /* 头像+时间区域 */
+  /* 内容区域 */
+  /* 文字消息容器 */
+  /* 带回执按钮容器 */
+  /* 消息气泡通用样式 */
+  /* 文字消息 */
+  /* 通话记录 */
+  /* 语音消息 */
+  /* 语音转文字区域 */
+  /* 回执按钮通用样式 */
+  /* 右侧消息(发送) */
+  /* 右侧消息气泡样式 */
+}
+.message-page .nav-header.data-v-61746e12 {
   padding: 20rpx;
   background: #fff;
   border-bottom: 1rpx solid #eee;
 }
-.shu .back-btn.data-v-61746e12 {
+.message-page .back-btn.data-v-61746e12 {
   width: 100rpx;
   font-size: 28rpx;
   text-align: center;
@@ -117,69 +34,147 @@
   align-items: center;
   gap: 10rpx;
 }
-.shu .back-btn image.data-v-61746e12 {
+.message-page .back-btn image.data-v-61746e12 {
   width: 25rpx;
   height: 25rpx;
 }
-.shu .message-list.data-v-61746e12 {
+.message-page .message-list.data-v-61746e12 {
   flex: 1;
   padding: 20rpx;
   background: #fff;
+  overflow-y: auto;
+  box-sizing: border-box;
 }
-.shu .message-item.data-v-61746e12 {
+.message-page .message-item.data-v-61746e12 {
   display: flex;
   align-items: flex-start;
-  margin: 30rpx 0;
+  margin: 42rpx 0;
+  gap: 20rpx;
 }
-.shu .avatar.data-v-61746e12 {
-  width: 80rpx;
-  height: 80rpx;
-  border-radius: 8rpx;
+.message-page .avatar-section.data-v-61746e12 {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.message-page .avatar.data-v-61746e12 {
+  width: 92rpx;
+  height: 92rpx;
+  border-radius: 50%;
   flex-shrink: 0;
 }
-.shu .voice-message.data-v-61746e12 {
-  position: relative;
+.message-page .msg-time.data-v-61746e12 {
+  font-size: 32rpx;
+  color: #666;
+  white-space: nowrap;
+}
+.message-page .content-section.data-v-61746e12 {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  gap: 10rpx;
+}
+.message-page .user-info.data-v-61746e12 {
+  font-size: 28rpx;
+  color: #666;
+}
+.message-page .message-content.data-v-61746e12 {
+  display: flex;
+  align-items: center;
+}
+.message-page .text-message-wrapper.data-v-61746e12 {
   display: flex;
   align-items: flex-end;
+  gap: 10rpx;
+}
+.message-page .receipt-toggle-wrapper.data-v-61746e12 {
+  display: flex;
+  align-items: center;
 }
-.shu .voice-duration.data-v-61746e12 {
-  background: #a4b3d4;
-  padding: 20rpx 20rpx;
+.message-page .text-message.data-v-61746e12,
+.message-page .call-message.data-v-61746e12,
+.message-page .voice-message.data-v-61746e12,
+.message-page .file-message.data-v-61746e12 {
+  background: #7d89b1;
+  padding: 16rpx 20rpx;
   border-radius: 8rpx;
   color: #fff;
-  font-size: 28rpx;
-  position: relative;
-  min-width: 100rpx;
-  margin: 0 20rpx;
+  font-size: 32rpx;
+}
+.message-page .image-message.data-v-61746e12,
+.message-page .video-message.data-v-61746e12 {
+  border-radius: 8rpx;
+  overflow: hidden;
+  border: 1rpx solid #dcdcdc;
+  background: #ffffff;
+}
+.message-page .text-message.data-v-61746e12 {
+  max-width: 400rpx;
+  word-wrap: break-word;
+}
+.message-page .call-message.data-v-61746e12 {
   display: flex;
   align-items: center;
+  gap: 10rpx;
 }
-.shu .voice-duration image.data-v-61746e12 {
+.message-page .call-message image.data-v-61746e12 {
   width: 40rpx;
   height: 40rpx;
 }
-.shu .voice-duration text.data-v-61746e12 {
-  margin: 0 10rpx;
+.message-page .voice-message.data-v-61746e12 {
+  position: relative;
+  display: flex;
+  align-items: center;
+  gap: 10rpx;
+  min-width: 100rpx;
+  transition: background-image 0.12s linear;
 }
-.shu .right.data-v-61746e12 {
-  flex-direction: row-reverse;
+.message-page .voice-message image.data-v-61746e12 {
+  width: 40rpx;
+  height: 40rpx;
 }
-.shu .right .avatar.data-v-61746e12 {
-  border-radius: 50%;
+.message-page .file-message.data-v-61746e12 {
+  display: flex;
+  align-items: center;
+  gap: 12rpx;
+  max-width: 420rpx;
 }
-.shu .right .voice-message.data-v-61746e12 {
-  flex-direction: row-reverse;
+.message-page .file-name.data-v-61746e12 {
+  flex: 1;
+  min-width: 0;
+  font-size: 32rpx;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.message-page .image-preview.data-v-61746e12 {
+  width: 280rpx;
+  height: 220rpx;
+  display: block;
 }
-.shu .right .voice-duration.data-v-61746e12 {
-  background: #e9e9e9;
-  color: #5e5e5e;
+.message-page .video-message.data-v-61746e12 {
+  position: relative;
+  width: 280rpx;
+  height: 220rpx;
 }
-.shu .time.data-v-61746e12 {
-  font-size: 24rpx;
-  color: #999;
+.message-page .video-cover.data-v-61746e12 {
+  width: 100%;
+  height: 100%;
   display: block;
 }
-.shu .recording-dot.data-v-61746e12 {
+.message-page .video-play.data-v-61746e12 {
+  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;
+}
+.message-page .recording-dot.data-v-61746e12 {
   width: 16rpx;
   height: 16rpx;
   background: #eb6100;
@@ -188,25 +183,336 @@
   right: 10rpx;
   top: 10rpx;
 }
-.shu .footer.data-v-61746e12 {
-  padding: 20rpx 40rpx;
-  background: #f5f5f5;
+.message-page .voice-text-section.data-v-61746e12 {
+  display: flex;
+  align-items: flex-end;
+  gap: 20rpx;
+}
+.message-page .voice-text-content.data-v-61746e12 {
+  flex: 1;
+  background: #7d89b1;
+  padding: 20rpx;
+  border-radius: 8rpx;
+  font-size: 32rpx;
+  color: #fff;
+  line-height: 1.5;
+}
+.message-page .inline-receipt-button.data-v-61746e12,
+.message-page .receipt-button.data-v-61746e12 {
+  background: #565d6d;
+  color: #fff;
+  padding: 10rpx 20rpx;
+  border-radius: 8rpx;
+  font-size: 28rpx;
+  white-space: nowrap;
+  cursor: pointer;
+}
+.message-page .right.data-v-61746e12 {
+  flex-direction: row-reverse;
+}
+.message-page .right .avatar.data-v-61746e12 {
+  border-radius: 50%;
+}
+.message-page .right .content-section.data-v-61746e12 {
+  align-items: flex-end;
+}
+.message-page .right .user-info.data-v-61746e12 {
+  text-align: right;
+}
+.message-page .right .text-message.data-v-61746e12,
+.message-page .right .call-message.data-v-61746e12,
+.message-page .right .voice-message.data-v-61746e12,
+.message-page .right .file-message.data-v-61746e12,
+.message-page .right .voice-text-content.data-v-61746e12 {
+  background: #eeeeee;
+  color: #333333;
+  border: 1rpx solid #dcdcdc;
+}
+.message-page .footer.data-v-61746e12 {
+  padding: 16rpx 24rpx;
+  background: #eeeeee;
+  display: flex;
+  align-items: flex-end;
+  gap: 16rpx;
+  border-top: 4rpx solid transparent;
+  box-sizing: border-box;
+}
+.message-page .footer.active.data-v-61746e12 {
+  border-top-color: #dcdcdc;
+}
+.message-page .tool-btn.data-v-61746e12 {
+  width: 78rpx;
+  height: 78rpx;
+  border-radius: 4rpx;
+  background: #ffffff;
+  border: 1rpx solid #e5e7eb;
   display: flex;
   align-items: center;
-  border-top: 1rpx solid #eee;
+  justify-content: center;
+  flex-shrink: 0;
+  align-self: flex-end;
 }
-.shu .input-box.data-v-61746e12 {
+.message-page .center-area.data-v-61746e12 {
   flex: 1;
+  min-width: 0;
+  display: flex;
+  align-items: flex-end;
+}
+.message-page .press-to-talk.data-v-61746e12 {
+  width: 100%;
+  height: 78rpx;
+  border-radius: 4rpx;
+  background: #ffffff;
+  border: 1rpx solid #e5e7eb;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12rpx;
+}
+.message-page .press-text.data-v-61746e12 {
+  color: #6b7280;
+  font-size: 30rpx;
+  line-height: 1;
+}
+.message-page .text-input.data-v-61746e12 {
+  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;
+}
+.message-page .text-input.capped.data-v-61746e12 {
+  height: 234rpx;
+}
+.message-page .bottom-panel.data-v-61746e12 {
+  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;
+}
+.message-page .bottom-panel.open.data-v-61746e12 {
+  border-top-color: #dcdcdc;
+  max-height: 380rpx;
+  opacity: 1;
+  transform: translateY(0);
+}
+.message-page .more-panel.data-v-61746e12 {
+  background: #eeeeee;
+  padding: 30rpx 24rpx;
+  box-sizing: border-box;
+}
+.message-page .emoji-panel.data-v-61746e12 {
+  background: #eeeeee;
+  padding: 20rpx 24rpx;
+  max-height: 320rpx;
+  overflow-y: auto;
+}
+.message-page .emoji-grid.data-v-61746e12 {
+  display: grid;
+  grid-template-columns: repeat(8, 1fr);
+  gap: 12rpx;
+}
+.message-page .emoji-item.data-v-61746e12 {
+  height: 52rpx;
+  line-height: 52rpx;
+  text-align: center;
+  font-size: 36rpx;
+  border-radius: 4rpx;
+}
+.message-page .more-grid.data-v-61746e12 {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+}
+.message-page .more-item.data-v-61746e12 {
+  width: 25%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 16rpx;
+}
+.message-page .more-icon.data-v-61746e12 {
+  width: 96rpx;
+  height: 96rpx;
+  border-radius: 4rpx;
   background: #ffffff;
-  height: 80rpx;
-  line-height: 80rpx;
+  border: 1rpx solid #e5e7eb;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.message-page .more-text.data-v-61746e12 {
+  font-size: 28rpx;
+  color: #6b7280;
+  white-space: nowrap;
+}
+.message-page .media-preview-mask.data-v-61746e12 {
+  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;
+}
+.message-page .media-preview-content.data-v-61746e12 {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  box-sizing: border-box;
+}
+.message-page .media-preview-content.video-mode.data-v-61746e12 {
+  width: 100vw;
+  height: 100vh;
+  background: transparent;
+  border-radius: 0;
+}
+.message-page .media-preview-content.image-mode.data-v-61746e12 {
+  width: 100vw;
+  height: 100vh;
+  background: transparent;
+  border-radius: 0;
+}
+.message-page .media-preview-video.data-v-61746e12 {
+  width: 100vw;
+  height: 100vh;
+  object-fit: cover;
+}
+.message-page .media-preview-exit.data-v-61746e12 {
+  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;
+}
+.message-page .media-preview-swiper.data-v-61746e12 {
+  width: 100vw;
+  height: 100vh;
+}
+.message-page .media-preview-swiper-item.data-v-61746e12 {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+.message-page .media-preview-image.fit-x.data-v-61746e12 {
+  width: 100vw;
+  height: auto;
+}
+.message-page .file-dialog-mask.data-v-61746e12 {
+  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;
+}
+.message-page .file-dialog.data-v-61746e12 {
+  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;
+}
+.message-page .file-dialog-header.data-v-61746e12 {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 18rpx;
+}
+.message-page .file-dialog-name.data-v-61746e12 {
+  max-width: 620rpx;
+  font-size: 30rpx;
+  color: #333;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
   text-align: center;
+}
+.message-page .file-dialog-actions.data-v-61746e12 {
+  display: flex;
+  justify-content: center;
+  gap: 24rpx;
+  padding-top: 40rpx;
+}
+.message-page .file-dialog-btn.data-v-61746e12 {
+  min-width: 120rpx;
+  height: 64rpx;
+  line-height: 64rpx;
+  text-align: center;
+  background: #575d6d;
+  color: #fff;
+  font-size: 28rpx;
   border-radius: 8rpx;
-  margin-right: 20rpx;
+}
+.message-page .file-dialog-btn.ghost.data-v-61746e12 {
+  background: #f1f2f4;
   color: #666;
-  border: 1px solid #eee;
 }
-.shu .emoji-btn image.data-v-61746e12 {
-  width: 50rpx;
-  height: 50rpx;
+.message-page .record-mask.data-v-61746e12 {
+  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;
+}
+.message-page .record-panel.data-v-61746e12 {
+  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;
+}
+.message-page .record-title.data-v-61746e12 {
+  font-size: 32rpx;
+  color: #fff;
+}
+.message-page .record-time.data-v-61746e12 {
+  font-size: 42rpx;
+  font-weight: 600;
+  color: #fff;
+}
+.message-page .record-tip.data-v-61746e12 {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.9);
+}
+.message-page .record-tip.danger.data-v-61746e12 {
+  color: #ffb2b2;
 }

+ 183 - 0
utils/parent-message-factory.js

@@ -0,0 +1,183 @@
+function getCurrentTimeLabel() {
+  const now = new Date()
+  const hours = String(now.getHours()).padStart(2, '0')
+  const minutes = String(now.getMinutes()).padStart(2, '0')
+  return `${hours}:${minutes}`
+}
+
+function createBaseMessage(payload = {}) {
+  return {
+    direction: payload.direction || 'right',
+    department: payload.department || '家长',
+    name: payload.name || '我',
+    time: payload.time || getCurrentTimeLabel(),
+    ...payload
+  }
+}
+
+export function createTextMessage(payload = {}) {
+  return createBaseMessage({
+    type: 'text',
+    content: payload.content || '',
+    needReceipt: Boolean(payload.needReceipt),
+    receiptStatus: payload.receiptStatus || 'unread',
+    showReceiptToggle: payload.showReceiptToggle ?? false,
+    ...payload
+  })
+}
+
+export function createVoiceMessage(payload = {}) {
+  return createBaseMessage({
+    type: 'voice',
+    duration: payload.duration || '00',
+    isRecording: Boolean(payload.isRecording),
+    voicePreview: payload.voicePreview || '',
+    voiceText: payload.voiceText || '',
+    needReceipt: Boolean(payload.needReceipt),
+    receiptStatus: payload.receiptStatus || 'unread',
+    ...payload
+  })
+}
+
+export function createImageMessage(payload = {}) {
+  return createBaseMessage({
+    type: 'image',
+    imageUrl: payload.imageUrl || '/static/logo.png',
+    ...payload
+  })
+}
+
+export function createFileMessage(payload = {}) {
+  return createBaseMessage({
+    type: 'file',
+    fileName: payload.fileName || '未命名文件',
+    fileUrl: payload.fileUrl || '',
+    ...payload
+  })
+}
+
+export function createVideoMessage(payload = {}) {
+  return createBaseMessage({
+    type: 'video',
+    coverUrl: payload.coverUrl || '/static/logo.png',
+    videoUrl: payload.videoUrl || '',
+    duration: payload.duration || '',
+    ...payload
+  })
+}
+
+export function collectImageUrls(messages = []) {
+  return messages
+    .filter((item) => item.type === 'image' && item.imageUrl)
+    .map((item) => item.imageUrl)
+}
+
+export function getInitialParentMessages() {
+  return [
+    createTextMessage({
+      direction: 'left',
+      department: '一年级A班',
+      name: '张三',
+      time: '15:25',
+      content: '你好啊',
+      needReceipt: true,
+      receiptStatus: 'unread'
+    }),
+    createBaseMessage({
+      type: 'call',
+      direction: 'left',
+      department: '一年级A班',
+      name: '张三',
+      time: '15:26',
+      duration: '00:25'
+    }),
+    createBaseMessage({
+      type: 'call',
+      direction: 'right',
+      department: '家长',
+      name: '李四',
+      time: '15:30',
+      duration: '00:25'
+    }),
+    createVoiceMessage({
+      direction: 'left',
+      department: '一年级A班',
+      name: '张珊',
+      time: '15:23',
+      duration: '24',
+      voicePreview: '这段音频前面的字',
+      voiceText: '这是转写这段音频的所有内容,但转成文字的功能暂时不够,设计图先有,转成文字后,"确认阅读"跟着。',
+      needReceipt: true,
+      receiptStatus: 'unread'
+    }),
+    createVoiceMessage({
+      direction: 'right',
+      department: '家长',
+      name: '李四',
+      time: '18:28',
+      duration: '15',
+      voicePreview: '这是我发的语音',
+      voiceText: '这是我发的语音消息的完整转写内容。',
+      needReceipt: true,
+      receiptStatus: 'read',
+      showReceiptToggle: false
+    }),
+    createVoiceMessage({
+      direction: 'left',
+      department: '一年级A班',
+      name: '张三',
+      time: '17:25',
+      duration: '16',
+      isRecording: true,
+      voicePreview: '正在录音中'
+    }),
+    createTextMessage({
+      direction: 'right',
+      department: '家长',
+      name: '王五',
+      time: '19:30',
+      content: '这是我刚发的消息',
+      showReceiptToggle: true
+    }),
+    createTextMessage({
+      direction: 'right',
+      department: '家长',
+      name: '李四',
+      time: '19:25',
+      content: '这是我之前发的消息,已经勾选了带回执',
+      needReceipt: true,
+      receiptStatus: 'read',
+      showReceiptToggle: false
+    }),
+    createImageMessage({
+      direction: 'left',
+      department: '一年级A班',
+      name: '班主任',
+      time: '19:40',
+      imageUrl: '/static/logo.png'
+    }),
+    createImageMessage({
+      direction: 'right',
+      department: '家长',
+      name: '李四',
+      time: '19:40',
+      imageUrl: '/static/images/deviceunlogin.png'
+    }),
+    createFileMessage({
+      direction: 'right',
+      department: '家长',
+      name: '李四',
+      time: '19:41',
+      fileName: '归档.zip',
+      fileUrl: 'https://www.iconfont.cn/api/project/download.zip?spm=a313x.manage_type_myprojects.i1.d7543c303.6a0d3a81LqAtP4&pid=5081296&ctoken=null'
+    }),
+    createVideoMessage({
+      direction: 'left',
+      department: '一年级A班',
+      name: '班主任',
+      time: '19:42',
+      coverUrl: '/static/images/deviceunlogin.png',
+      videoUrl: 'https://v2.cri.cn/c89e53c6-0bc7-45ca-ac11-a385002d7d11/cb5a6d96-d0c4-4fd0-a895-b6135667d84a/video/2df23555-d075-4988-9d06-0f3564517974.mp4'
+    })
+  ]
+}

+ 122 - 0
utils/parent-message-send.js

@@ -0,0 +1,122 @@
+import {
+  createTextMessage,
+  createImageMessage,
+  createVideoMessage,
+  createFileMessage,
+  createVoiceMessage
+} from '@/utils/parent-message-factory'
+
+function normalizeUserMeta(userMeta = {}) {
+  return {
+    direction: userMeta.direction || 'right',
+    department: userMeta.department || '家长',
+    name: userMeta.name || '我'
+  }
+}
+
+export function buildTextOutgoingMessage(text, userMeta = {}) {
+  const content = (text || '').trim()
+  if (!content) return null
+  return createTextMessage({
+    ...normalizeUserMeta(userMeta),
+    content,
+    showReceiptToggle: true
+  })
+}
+
+export async function pickImageOutgoingMessages(userMeta = {}) {
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count: 9,
+      sizeType: ['compressed'],
+      sourceType: ['album'],
+      success: (res) => {
+        const messages = (res.tempFilePaths || []).map((imageUrl) =>
+          createImageMessage({ ...normalizeUserMeta(userMeta), imageUrl })
+        )
+        resolve(messages)
+      },
+      fail: reject
+    })
+  })
+}
+
+export async function shootImageOutgoingMessage(userMeta = {}) {
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count: 1,
+      sizeType: ['compressed'],
+      sourceType: ['camera'],
+      success: (res) => {
+        const first = (res.tempFilePaths || [])[0]
+        resolve(first ? createImageMessage({ ...normalizeUserMeta(userMeta), imageUrl: first }) : null)
+      },
+      fail: reject
+    })
+  })
+}
+
+export async function pickVideoOutgoingMessage(userMeta = {}) {
+  return new Promise((resolve, reject) => {
+    uni.chooseVideo({
+      sourceType: ['camera', 'album'],
+      compressed: true,
+      maxDuration: 60,
+      success: (res) => {
+        const videoUrl = res.tempFilePath || ''
+        if (!videoUrl) return resolve(null)
+        const duration = Number.isFinite(res.duration)
+          ? `${String(Math.floor(res.duration / 60)).padStart(2, '0')}:${String(Math.floor(res.duration % 60)).padStart(2, '0')}`
+          : ''
+        resolve(
+          createVideoMessage({
+            ...normalizeUserMeta(userMeta),
+            videoUrl,
+            coverUrl: res.thumbTempFilePath || '/static/logo.png',
+            duration
+          })
+        )
+      },
+      fail: reject
+    })
+  })
+}
+
+export async function pickFileOutgoingMessage(userMeta = {}) {
+  if (!uni.chooseMessageFile) {
+    return null
+  }
+  return new Promise((resolve, reject) => {
+    uni.chooseMessageFile({
+      count: 1,
+      type: 'file',
+      success: (res) => {
+        const file = (res.tempFiles || [])[0]
+        if (!file) return resolve(null)
+        resolve(
+          createFileMessage({
+            ...normalizeUserMeta(userMeta),
+            fileName: file.name || '未命名文件',
+            fileUrl: file.path || ''
+          })
+        )
+      },
+      fail: reject
+    })
+  })
+}
+
+export function buildVoiceOutgoingMessage({
+  durationSeconds = 0,
+  audioUrl = '',
+  voiceText = ''
+} = {}, userMeta = {}) {
+  const safeDuration = Math.max(1, Math.floor(durationSeconds || 0))
+  return createVoiceMessage({
+    ...normalizeUserMeta(userMeta),
+    duration: String(safeDuration),
+    audioUrl,
+    voicePreview: '语音消息',
+    voiceText
+  })
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов