iOS开发之-Paging Mode

本文详细介绍了如何配置分页模式滚动视图,包括滚动视图控制器类的实现、内容大小设置、子视图配置及分页逻辑,以及使用UIPageControl类进行分页控制。

Scrolling Using Paging Mode


Configuring Paging Mode


1. Configuring a scroll view to support paging mode requires that code be implemented in the scroll view’s controller class.


2. Aside from the standard scroll view initialization described in “Creating and Configuring Scroll Views,” you must also set the pagingMode property to YES.


3. The contentSize property of a paging scroll view is set so that it fills the height of the screen and that the width is a multiple of the width of the device screen multiplied by the number of pages to be displayed.


4. Additionally, the scroll indicators should be disabled, because the relative location as the user is touching the screen is irrelevant, or is shown using a UIPageControl.

Configuring Subviews of a Paging Scroll View


1. If the content is small, you could draw the entire contents at once, in a single view that is the size of the scroll view’s contentSize. 


2. When your application needs to display a large number of pages or drawing the page content can take some time, your application should use multiple views to display the content, one view for each page. 


3. Supporting a large number of pages in a paging scroll view can be accomplished using only three view instances, each the size of the device screen: one view displays current page, another displays the previous page, and third displays the next page. The views are reused as the user scrolls through the pages.


4. The controller is responsible for keeping track of which page is the current page.


5. To determine when the pages need to be reconfigured because the user is scrolling the content, the scroll view requires a delegate that implements the scrollViewDidScroll: method. The implementation of this method should track the contentOffset of the scroll view, and when it passes the mid point of the current view’s width, the views should be reconfigured, moving the view that is no longer visible on the screen to the position that represents the next or previous page. The delegate should then inform the view that it should draw the content appropriate for the new location it the represents.

UIPageControl Class


1. 



我是用的uniapp开发的app项目,代码如下:<route lang="json5" type="home"> { layout: 'default', style: { navigationBarTitleText: 'AI聊天', navigationStyle: 'custom', disableScroll: true, 'app-plus': { bounce: 'none', }, }, } </route> <template> <PageLayout :navTitle="navTitle" type="popup" :navBgTransparent="true" :navLeftArrow="false" :enableDropdown="true" :arrowIcon="arrowIcon" @navBack="drawerShow = true" @navRight="handleNewConversation" @dropdownClick="handleDropdownClick"> <template #navLeft> <image :src="moreList" style="width: 24px; height: 24px;" /> </template> <template #navRight> <image :src="addConv" style="width: 24px; height: 24px;" /> </template> <!-- 自定义下拉框内容 --> <!-- <template #dropdown> <view class="dropdown-content"> <text>知识库</text> </view> </template> --> <!-- 页面内容 --> <view class="wrap"> <view v-if="isAIAnswering" class="stop-answer-btn" @click="handleStopAnswer"> <text>停止回答</text> </view> <z-paging ref="paging" v-model="dataList" :fixed="false" :auto="false" :paging-enabled="false" use-chat-record-mode use-virtual-list cell-height-mode="dynamic" safe-area-inset-bottom bottom-bg-color="#e5e5e5"> <!-- 自定义空数据视图 --> <template #empty> <view class="empty-container"> <view> <image :src="logo" class="empty-logo" mode="aspectFit" /> </view> <view class="empty-title">欢迎使用水利安全生产知识库</view> <view class="empty-subtitle">我能理解人类语言、生成内容,是你的问答智能助手。</view> </view> </template> <template #cell="{ item }"> <view style="transform: scaleY(-1)"> <ai-chat-item :item="item" :isAnswering="isAIAnswering && item.sendTimeId === currentAIMessageId" @copy="handleCopy" @regenerate="handleRegenerate" /> </view> </template> <template #bottom> <ai-chat-input-bar ref="inputBar" @send="doSend" @sendImage="handleImage" /> </template> </z-paging> </view> <!-- 左侧弹出框组件 --> <ai-chat-history v-model="drawerShow" :conversationList="conversationList" :currentConversationId="conversationId" @select="handleSelectConversation" /> </PageLayout> </template> <script lang="ts" setup> import { ref, onMounted, onBeforeUnmount } from 'vue' import { onLaunch, onShow, onHide, onLoad, onReady } from '@dcloudio/uni-app' import { useUserStore } from '@/store/user' import { http } from '@/utils/http' import { useToast } from 'wot-design-uni' import { useParamsStore } from '@/store/page-params' import { formatDate } from '@/common/uitls' import aiChatInputBar from './components/AiChatInputBar.vue' import aiChatItem from './components/AiChatItem.vue' import aiChatHistory from './components/AiChatHistory.vue' import logo from '@/static/logo.png' import moreList from '@/static/more-list.png'; import addConv from '@/static/add-conv.png'; import arrowIcon from '@/static/arrow-right.png' import qs from 'qs' import signMd5Utils from '@/utils/signMd5Utils' // #ifdef APP-PLUS import { EventSourcePolyfill } from 'event-source-polyfill' // #endif defineOptions({ name: 'aiChat', options: { styleIsolation: 'apply-shared', }, }) // API 接口定义 const api = { sendChat: '/airag/chat/send', getConversations: '/airag/chat/conversations', getMessages: '/airag/chat/messages', stopChat: '/airag/chat/stop', uploadUrl: '/sys/common/upload', } const toast = useToast() const userStore = useUserStore() const paramsStore = useParamsStore() const paging = ref(null) const inputBar = ref(null) const navTitle = ref('新对话') const dataList = ref([]) const conversationId = ref('') const appId = ref('1980557694009438210') const requestId = ref('') const drawerShow = ref(false) const conversationList = ref([]) let eventSource = null // EventSource 实例 let currentAIMessageId = ref('') // 当前 AI 消息的 ID let currentAIMessage = ref('') // 累积的 AI 消息内容 // 添加状态变量 const isAIAnswering = ref(false) const handleDropdownClick = () => { console.log('下拉框被点击') // 处理下拉框点击事件 } // 页面初始化 const init = async () => { const localData = paramsStore.getPageParams('aiChat') const params = localData?.data if (params?.appId) { appId.value = params.appId } if (params?.title) { navTitle.value = params.title } await loadConversation() // 如果没有会话ID,手动触发空状态 if (!conversationId.value) { paging.value?.complete([]) } } // 加载会话列表 const loadConversation = async () => { try { // 添加您需要的参数 const params = { appId: appId.value, // 其他参数... } const res = await http.get(api.getConversations, params) if (res.success && res.result?.length) { conversationList.value = res.result || [ { "id": "2c9180839a09dce2019a0ef293460037", "title": "消防安全的", "messages": null, "app": null, "createTime": "2025-10-24 09:43:16" }, { "id": "237A8A0151", "title": "啥是电影蒙发更广泛的", "messages": null, "app": null, "createTime": "2025-10-23 14:53:00" } ] conversationId.value = res.result[0].id } } catch (e) { console.error('加载会话失败', e) toast.error('加载会话失败') } } // 加载全部消息(不分页) const loadAllMessages = async () => { if (!conversationId.value) { dataList.value = [] return } try { const res = await http.get(`${api.getMessages}/${conversationId.value}`) if (res.success) { dataList.value = analysis(res.result || []) } else { dataList.value = [] toast.error(res.message || '加载消息失败') } } catch (e) { console.error('加载消息失败', e) dataList.value = [] toast.error('加载消息失败') } } // 数据分析处理 const analysis = (data: any[]) => { return data.map((item) => { const time = formatDate(item.sendTime || new Date(), 'yyyy-MM-dd hh:mm:ss') const timeStr = String(time || '') const id = timeStr.replace(/:/g, '').replace(/-/g, '').replace(' ', '') return { ...item, sendTimeId: id, msgFrom: item.role === 'user' ? userStore.userInfo.userid : 'ai', fromUserName: item.role === 'user' ? userStore.userInfo.realname : 'AI助手', fromAvatar: item.role === 'user' ? userStore.userInfo.avatar : '', msgData: item.content, msgType: item.images?.length ? 'image' : 'text', sendTime: timeStr, } }) } // 发送消息到界面 const send = ({ msgData, msgType, isAI = false, messageId = '' }) => { const time = formatDate(new Date(), 'yyyy-MM-dd hh:mm:ss') const timeStr = String(time || '') const id = messageId || timeStr.replace(/:/g, '').replace(/-/g, '').replace(' ', '') paging.value?.addChatRecordData([ { fromUserName: isAI ? 'AI助手' : userStore.userInfo.realname, msgFrom: isAI ? 'ai' : userStore.userInfo.userid, msgData, fromAvatar: isAI ? '' : userStore.userInfo.avatar, sendTime: timeStr, sendTimeId: id, msgType, }, ]) return id } // 处理新建会话 const handleNewConversation = () => { // 1. 检查是否已经是新会话(没有 conversationId 或 dataList 为空) if (!conversationId.value || dataList.value.length === 0) { toast.info('已经在新会话中') return } // 2. 如果正在回答中,先停止回答 if (isAIAnswering.value) { handleStopAnswer() } // 3. 清空所有相关数据 clearConversationData() // 4. 显示空状态页面 paging.value?.complete([]) } // 清空会话数据 const clearConversationData = () => { // 清空会话 ID conversationId.value = '' // 清空消息列表 dataList.value = [] // 关闭 EventSource 连接 closeEventSource() // 重置 AI 回答状态 isAIAnswering.value = false currentAIMessageId.value = '' currentAIMessage.value = '' requestId.value = '' // 重置标题为"新对话" navTitle.value = '新对话' } // 更新 AI 消息(用于流式显示) const updateAIMessage = (messageId: string, content: string) => { // 查找并更新对应的消息 const index = dataList.value.findIndex(item => item.sendTimeId === messageId) if (index !== -1) { dataList.value[index].msgData = content } } // 关闭 EventSource 连接 const closeEventSource = () => { if (eventSource) { eventSource.close() eventSource = null } } // 发送文本消息(使用 EventSource) const doSend = async (textMsg: string) => { const content = textMsg.trim() if (!content) return send({ msgData: content, msgType: 'text', isAI: false }) // 关闭之前的连接 closeEventSource() try { const params = { appId: appId.value, content, conversationId: conversationId.value || '2c9180839a09dce2019a0ef293460037', images: JSON.stringify([]), responseMode: 'streaming', topicId: '2c9180839a09dce2019a0ef293440036', } // ✅ 在条件编译之前声明 url 变量 let url = '' // #ifdef H5 // H5 端使用原生 EventSource const queryParams = new URLSearchParams(params) url = `/jeecgboot${api.sendChat}?${queryParams.toString()}` eventSource = new EventSource(url) // #endif // #ifdef APP-PLUS // APP 端使用 EventSourcePolyfill const queryString = qs.stringify(params) url = `/jeecgboot${api.sendChat}?${queryString}` console.log('app端>>>', url) let sign = signMd5Utils.getSign(url, params) let vSign = signMd5Utils.getVSign(params, sign) eventSource = new EventSourcePolyfill(url, { headers: { 'X-Access-Token': userStore.userInfo.token, 'X-Tenant-Id': userStore.userInfo.tenantId, // 'X-Sign': sign, // 'V-Sign': vSign, // 'X-TIMESTAMP': signMd5Utils.getTimestamp(), }, heartbeatTimeout: 120000, }) // #endif isAIAnswering.value = true currentAIMessage.value = '' currentAIMessageId.value = '' // 监听消息事件 eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data) console.log('收到 SSE 消息:', data) handleSSEMessage(data) } catch (e) { console.error('解析 SSE 消息失败', e) } } // 监听错误事件 eventSource.onerror = (error) => { console.error('SSE 连接错误', error) closeEventSource() isAIAnswering.value = false if (!currentAIMessage.value) { toast.error('连接失败,请重试') } } } catch (e) { console.error('发送消息失败', e) toast.error('发送失败') isAIAnswering.value = false } } const doSend2 = async (textMsg: string) => { // #ifdef H5 await doSendForH5(textMsg) // #endif // #ifdef APP-PLUS await doSendForApp(textMsg) // #endif } // APP 端使用 uni.request 解析 SSE 流 const doSendForApp = async (textMsg: string) => { const content = textMsg.trim() if (!content) return send({ msgData: content, msgType: 'text', isAI: false }) try { const params = { appId: appId.value, content, conversationId: conversationId.value || '2c9180839a09dce2019a0ef293460037', images: JSON.stringify([]), responseMode: 'streaming', topicId: '2c9180839a09dce2019a0ef293440036', } // 使用 qs.stringify 构建查询字符串 const queryString = qs.stringify(params) const url = `/jeecgboot${api.sendChat}?${queryString}`; // let params = options.query // if (options.data && Object.keys(options.data).length > 0) { // params = Object.assign({}, options?.query, options?.data) // } let sign = signMd5Utils.getSign(url, params) let vSign = signMd5Utils.getVSign(params, sign) isAIAnswering.value = true currentAIMessage.value = '' currentAIMessageId.value = '' console.log('请求地址>>>', url) const downloadTask = uni.downloadFile({ url: `/jeecgboot${api.sendChat}?${queryString}`, header: { 'X-Access-Token': userStore.userInfo.token, 'X-Tenant-Id': userStore.userInfo.tenantId, 'Accept': 'text/event-stream', 'X-Sign': sign, 'V-Sign': vSign, 'X-TIMESTAMP': signMd5Utils.getTimestamp(), }, success: (res) => { console.log('下载完成', res) if (res.statusCode === 200) { uni.getFileSystemManager().readFile({ filePath: res.tempFilePath, encoding: 'utf8', success: (fileRes) => { const content = fileRes.data parseSSEChunk(content) } }) } }, fail: (err) => { console.error('请求失败', err) toast.error('连接失败,请重试') isAIAnswering.value = false } }) downloadTask.onProgressUpdate((res) => { console.log('下载进度', res.progress) }) } catch (e) { console.error('发送消息失败', e) toast.error('发送失败') isAIAnswering.value = false } } // 解析 SSE 数据块 let sseBuffer = '' const parseSSEChunk = (chunk: string) => { sseBuffer += chunk // 按行分割 const lines = sseBuffer.split('\n') sseBuffer = lines.pop() || '' // 保留最后一个不完整的行 for (const line of lines) { if (line.startsWith('data:')) { try { const jsonStr = line.substring(5).trim() if (jsonStr) { const data = JSON.parse(jsonStr) handleSSEMessage(data) } } catch (e) { console.error('解析 SSE 消息失败', e, line) } } } } const doSendForH5 = async (textMsg: string) => { const content = textMsg.trim() if (!content) return // 先在界面显示用户消息 send({ msgData: content, msgType: 'text', isAI: false }) // 关闭之前的连接 closeEventSource() try { const token = uni.getStorageSync('token') // 构建 URL 参数 const params = new URLSearchParams({ appId: appId.value, content, conversationId: conversationId.value || '2c9180839a09dce2019a0ef293460037', images: JSON.stringify([]), responseMode: 'streaming', topicId: '2c9180839a09dce2019a0ef293440036', }) const url = `/jeecgboot${api.sendChat}?${params.toString()}` // 创建 EventSource 连接 eventSource = new EventSource(url) // 重置当前消息 currentAIMessage.value = '' currentAIMessageId.value = '' // 监听消息事件 eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data) console.log('收到 SSE 消息:', data) handleSSEMessage(data) } catch (e) { console.error('解析 SSE 消息失败', e) } } // 监听错误事件 eventSource.onerror = (error) => { console.error('SSE 连接错误', error) closeEventSource() // 如果还没有收到任何消息,显示错误提示 if (!currentAIMessage.value) { toast.error('连接失败,请重试') } } } catch (e) { console.error('发送消息失败', e) toast.error('发送失败') } } // 处理 SSE 消息 const handleSSEMessage2 = (data: any) => { switch (data.event) { case 'INIT_REQUEST_ID': // 初始化请求 ID requestId.value = data.requestId if (data.conversationId) { conversationId.value = data.conversationId } console.log('初始化请求:', data) break case 'MESSAGE': // 流式消息块 const message = data.data?.message || '' currentAIMessage.value += message // 如果是第一次收到消息,创建 AI 消息项 if (!currentAIMessageId.value) { currentAIMessageId.value = send({ msgData: currentAIMessage.value, msgType: 'text', isAI: true, }) } else { // 更新现有消息 updateAIMessage(currentAIMessageId.value, currentAIMessage.value) } break case 'MESSAGE_END': // 消息结束 console.log('消息接收完成') closeEventSource() // 重置状态 currentAIMessage.value = '' currentAIMessageId.value = '' break case 'ERROR': case 'FLOW_ERROR': // 错误处理 const errorMsg = data.data?.message || '发送失败' toast.error(errorMsg) closeEventSource() // 重置状态 currentAIMessage.value = '' currentAIMessageId.value = '' break default: console.warn('未知的消息类型:', data.event) } } // 修改 handleSSEMessage 函数 const handleSSEMessage = (data: any) => { switch (data.event) { case 'INIT_REQUEST_ID': requestId.value = data.requestId if (data.conversationId) { conversationId.value = data.conversationId } isAIAnswering.value = true // 开始回答 break case 'MESSAGE': const message = data.data?.message || '' currentAIMessage.value += message if (!currentAIMessageId.value) { currentAIMessageId.value = send({ msgData: currentAIMessage.value, msgType: 'text', isAI: true, }) } else { updateAIMessage(currentAIMessageId.value, currentAIMessage.value) } break case 'MESSAGE_END': console.log('消息接收完成') closeEventSource() isAIAnswering.value = false // 回答结束 currentAIMessage.value = '' currentAIMessageId.value = '' break case 'ERROR': case 'FLOW_ERROR': const errorMsg = data.data?.message || '发送失败' toast.error(errorMsg) closeEventSource() isAIAnswering.value = false // 回答结束 currentAIMessage.value = '' currentAIMessageId.value = '' break } } // 停止回答 const handleStopAnswer = async () => { if (!requestId.value) return try { await http.post(api.stopChat, { requestId: requestId.value }) closeEventSource() isAIAnswering.value = false toast.success('已停止回答') } catch (e) { console.error('停止回答失败', e) toast.error('停止失败') } } // 复制消息 const handleCopy = (item: any) => { uni.setClipboardData({ data: item.msgData.replace(/<[^>]+>/g, ''), success: () => { // toast.success('复制成功') } }) } // 重新生成 const handleRegenerate = (item: any) => { // 找到该消息之前的用户消息 const index = dataList.value.findIndex(i => i.sendTimeId === item.sendTimeId) if (index > 0) { const userMsg = dataList.value[index - 1] if (userMsg.msgFrom === userStore.userInfo.userid) { doSend(userMsg.msgData) } } } // 处理图片上传 const handleImage = async (type: string) => { uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: [type], success: async (res) => { uni.showLoading({ title: '上传中...' }) try { const uploadRes = await uni.uploadFile({ url: http.baseUrl + api.uploadUrl, filePath: res.tempFilePaths[0], name: 'file', header: { 'X-Access-Token': uni.getStorageSync('token'), }, }) const data = JSON.parse(uploadRes.data) if (data.success) { send({ msgData: data.message, msgType: 'image' }) } else { toast.error('上传失败') } } catch (e) { toast.error('上传失败') } finally { uni.hideLoading() } }, }) } // 选择会话 const handleSelectConversation = (item) => { conversationId.value = item.id navTitle.value = item.title || 'AI助手' loadAllMessages() // 改为调用新的加载函数 } init() onLoad(() => { uni.hideTabBar() }) onShow(() => { uni.hideTabBar() }) onMounted(() => { }) onBeforeUnmount(() => { // 页面卸载时关闭 EventSource 连接 closeEventSource() }) </script> <style lang="scss" scoped> :deep(.pageContent) { background: transparent !important; } .wrap { height: 100%; background-image: url('@/static/bgImg.png'); background-size: cover; background-position: center; background-repeat: no-repeat; .z-paging-content { background: transparent; // 移除原有的渐变背景 } .stop-answer-btn { position: fixed; bottom: 400rpx; // 输入框上方 left: 50%; transform: translateX(-50%); width: 208rpx; height: 64rpx; border-radius: 40rpx; background: #FFFFFF; border: 2rpx solid #E2E2E2; display: flex; justify-content: center; align-items: center; padding: 12rpx 24rpx; box-sizing: border-box; z-index: 100; text { font-size: 28rpx; color: #333; } &:active { background: #f5f5f5; } } .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; .empty-logo { width: 67.74upx; height: 80upx; margin-bottom: 24upx; } .empty-title { font-family: Alibaba PuHuiTi 3.0; font-size: 40upx; font-weight: 600; line-height: normal; letter-spacing: 0px; color: rgba(0, 0, 0, 0.9); margin-bottom: 24upx; } .empty-subtitle { font-family: Alibaba PuHuiTi 3.0; font-size: 28upx; font-weight: normal; line-height: 48upx; text-align: center; color: rgba(0, 0, 0, 0.6); } } } :deep(.zp-page-bottom-container) { background: transparent !important; margin: 24upx; } </style> 现在在H5端正常,但是app端报错:发送消息失败, TypeError: XMLHttpRequest2 is not a constructor at pages/knowledge/AiKnowledgeManagerList.vue
10-28
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值