// uniapp people side tools import './ican-H5Api.js' // 对齐H5Api: https://ext.dcloud.net.cn/plugin?id=415 // 注意要取消默认自带的 showToast https://uniapp.dcloud.io/api/system/clipboard?id=%e6%b3%a8%e6%84%8f const BASEND_TYPE_DEFAULT = 'SERVER' // one of { SERVER: 服务器, UNICLOUD_FUNC: 云函数, UNICLOUD_OBJECT: 云对象 } export default { // 用直观的色彩,代替 uview 的命名方式: RED: 'error', GREEN: 'success', BLUE: 'primary', YELLOW: 'warning', GREY: 'info', BLACK_TOAST: 'default', WHITE_BUTTON: 'default', thisPage () { return this.__page__ ? this // 1) constructor.name==='VueComponent' 只在 development 环境有用,在 production 环境会被简化成 'o'。2)对于组件内定义的 i18nText,要使用 this 来获得组建内的 i18nText,而不是 getCurrentPages[...] 去访问全局页面的 i18nText。 : getCurrentPages()[getCurrentPages().length - 1] || {} // [20220401] 发现在 topWindow 里, getCurrentPages 是 undefined。 // 在 App.vue 中调用 getCurrentPages() 返回的是空数组 [],因此在这里默认 {} 做保护。 }, // 输出命令行提示,可用来取代 console.log/info/warn/error cclog (...args) { const pageName = this.thisPage()?.route || 'VueApp' console.log('%c ' + JSON.stringify({ time: new Date().toJSON(), page: pageName }), 'color:blue;background:lightgrey', ...args) }, ccinfo (...args) { const pageName = this.thisPage()?.route || 'VueApp' console.info('%c ' + JSON.stringify({ time: new Date().toJSON(), page: pageName }), 'color:green;background:lightgrey', ...args) }, ccwarn (...args) { const pageName = this.thisPage().route || 'VueApp' console.warn('%c ' + JSON.stringify({ time: new Date().toJSON(), page: pageName }), 'color:orange;background:lightgrey', ...args) }, ccerror (...args) { const pageName = this.thisPage()?.route || 'VueApp' console.error('%c ' + JSON.stringify({ time: new Date().toJSON(), page: pageName }), 'color:red;background:lightgrey', ...args) }, ccdebug (...args) { if (process.env.NODE_ENV !== 'production') { const pageName = this.thisPage()?.route || 'VueApp' console.debug('%c ' + JSON.stringify({ time: new Date().toJSON(), page: pageName }), 'color:cyan;background:lightgrey', ...args) } }, cctitle (...args) { const pageName = this.thisPage().route || 'VueApp' console.debug('%c ' + JSON.stringify({ time: new Date().toJSON(), page: pageName }), 'color:cyan;background:lightgrey', ...args) }, localizeText (i18nText) { i18nText = i18nText?.__page__ ? this.i18nText // 如果挂载到具体页面的 computed { lote: wo.localizeText } 那么 this 就是当前页面,直接取用 this.i18nText 即可。 : i18nText || this.thisPage()?.i18nText // 如果传入i18n参数 ({zhCN:'...', enUS:'...'}) // 如果不是挂载到 Vue.prototype 而是 挂载到 wo 下调用,那么 this.i18nText 就报错了。因此通过 thisPage().i18nText 访问。 const mylang = getApp().$store.state.i18n.mylang // this.thisPage() 有可能为空(例如在 topWindow 里,或者在 App.vue 里),所以用 getApp().$store 更安全 return i18nText?.[mylang] || (typeof i18nText === 'string' ? i18nText : '') // 必须检测是否string,如果直接返回 i18nText 可能返回{}等,导致依赖于返回空值的前端出错 }, localeText () { // 专供绑定到 computed { lote: wo.localeText } 使用,这时 this 就是当前页面。 return this.i18nText?.[getApp().$store.state.i18n.mylang] || {} }, setBarTitles ({ windowTitle, pageTitle, pagesJson = this.pagesJson || wo?.pagesJson, envar = this.envar || wo?.envar } = {}) { const mylang = getApp()?.$store?.state?.i18n?.mylang // 不要用 pageNow.$store,防止在 App.vue 里无法获取当前页面。 const pageNow = this.thisPage() // 需要兼顾在 App.vue 时无法获取当前页面的情况,因为如果在 topWindow 里调用本函数,getApp() 和 getCurrentPages()[getCurrentPages().length-1] 就是 undefined。 uni.setNavigationBarTitle({ // 也会被用于浏览器的标签标题,因此要用 document.title 去覆盖 title: pageTitle || pageNow?.i18nText?.[mylang]?.tPageTitle || // 页面.vue 的 i18nText 对象 pageNow?.i18nPageTitle?.[mylang] || // 页面.vue 的 i18nPageTitle 变量 pagesJson?.pages?.find((page) => page.path === pageNow?.route)?.i18nPageTitle?.[mylang], // pages.json 的页面配置里 }) // #ifdef H5 document.title = windowTitle || wo?.envar?.callname?.[mylang] || pagesJson?.appInfo?.i18nText?.[mylang] || pagesJson?.globalStyle?.navigationBarTitleText // 必须放在 setNavigationBarTitle 之后,否则会被其覆盖掉。 // #endif // 必须要在有 tab 的页面里 setTabBarItem 才有效果 //const midIndex = parseInt(pagesJson?.tabBar?.list?.length/2) // 如果存在midButton,实际上tabBar.list.length必须为偶数。不过为了心安,再parseInt一下。 pagesJson?.tabBar?.list?.forEach((tab, tabIndex) => { if (tab.i18nText && tab.i18nText[mylang]) { uni.setTabBarItem({ // #ifdef H5 index: tabIndex, // + ((pagesJson?.tabBar?.midButton?.iconPath && tabIndex >= midIndex)?1:0), // H5 里,如果使用了 midButton,tabBarItem的index出现错位,需hack调整。推测,在H5里 midButton 作为一个普通tab被插入到 tabBar 里,导致 tabBar 的 index 和 pagesJson.tabBar.list 的 index 错位了。[20211031] 注意到,从 HBuilderX 3.2.12.20211029 起,在 H5 里也没有错位了。 // #endif // #ifndef H5 index: tabIndex, // #endif text: tab.i18nText[mylang], }) } }) // uni.showTabBar({}) // #ifdef H5 // 响应式方案:仅仅根据当前设备类型,如果是 PC 大屏幕,则始终显示 topWindow 并且隐藏顶部 navibar 和底部 tabBar。 if (pagesJson?.topWindow || envar?.hideBarsOnPC) { // 如果页头不是通过 pagesJson.topWindow 而是作为组件来引入个别页面,那么定义配置参数 hideBarsOnPC 来控制。 if (uni.getSystemInfoSync().model === 'PC') { if (window.innerWidth > (pagesJson?.topWindow?.matchMedia?.minWidth || 0)) { uni.hideTabBar() // 不知为何,同一个二级页面,如果第二次进入,就仍然会显示 navibar, 必须通过 setTimeout 执行才能彻底隐藏。 setTimeout(() => { document.getElementsByTagName('uni-page-head')?.[0]?.remove() }, 0) } } else { document.getElementsByTagName('uni-top-window')?.[0]?.remove() // 强制隐藏 topWindow,否则在手机浏览器里,topWindow 会遮挡掉 navibar。 } } // #endif }, makeServerUrl (route = '') { const envar = this.envar || wo?.envar || {} if (typeof route !== 'string') route = '' // 防止 route 为 null, undefined 等由于后台数据库默认值而造成的异常。 route = route.replace('\\', '/') if (/^https?:\/\//.test(route)) { return route } const protocol = envar.servProtocol || (process.env.NODE_ENV === 'production' ? 'https' : 'http') let hostname = envar.servHostname let port = envar.servPort // #ifdef H5 if (!hostname) { hostname = window?.location?.hostname } if (!port) { port = window?.location?.port?.replace(':', '') } // #endif if (!hostname) { // 如果没有配置 hostname,又不是在 H5 环境,则最后的默认值是 localhost hostname = 'localhost' } return `${protocol}://${hostname}:${port}/${route.replace(/^\//, '')}` }, makeBgUrl (path) { if (path) { return `url(${this.makeServerUrl(path)})` } return '' }, /** 统一 uni.request 和 uniCloud.callFunction 的调用方法,提供统一、透明的后台调用 * 返回值:{ _state, 成功结果或错误结果 },其中 _state 除了后台返回的,还可以是 * - CLIENT_BASEND_BROKEN: 前端发现后台断线 * - CLIENT_BASEND_TIMEOUT: 前端发现后台超时 * - CLINET_BASEND_EXCEPTION: 前端发现后台异常 **/ async callBasend ({ basendType = this.envar?.basendTypeDefault || wo?.envar?.basendTypeDefault || BASEND_TYPE_DEFAULT, httpMethod = 'POST', apiVersion = 'api', apiWho, apiTodo, apiWhat = {}, }) { const thisRoute = this.thisPage()?.route || 'VueApp' // 立刻保存 this.thisPage().route,因为在调用后台后,可能已切换到了其他页面。 const startTime = new Date().toJSON() let url = undefined let result = {} if (basendType === 'UNICLOUD_OBJECT') { const uniObj = uniCloud.importObject(apiWho) try { result = await uniObj[apiTodo](apiWhat) } catch (error) { result = { _state: 'CLINET_BASEND_EXCEPTION', error } } } else if (basendType === 'UNICLOUD_FUNC') { let { /* success, header, requestedId, */ result: resultCloud = {} } = await uniCloud .callFunction({ name: apiWho, data: { apiTodo, apiWhat, _passtoken: uni.getStorageSync('_passtoken'), // uniIdToken // uniCloud自动getStorageSync('uni_id_token')并传递为 uniIdToken;也可自行组装传入 uniIdToken }, }) .catch((error) => { // {errMsg, stack} = error if (/request:fail/.test(error.errMsg)) { // 后台云服务无法连接 return { _state: 'CLIENT_BASEND_BROKEN', error } } else { // 后台云服务返回异常 return { _state: 'CLIENT_BASEND_EXCEPTION', error } } }) result = resultCloud } else if (basendType === 'SERVER') { if (httpMethod === 'GET') { // 如果不是 POST 方法,要额外把参数JSON化 for (let key in apiWhat) { apiWhat[key] = JSON.stringify(apiWhat[key]) } } url = this.makeServerUrl(`${apiVersion}/${apiWho}/${apiTodo}`) let [error, { statusCode, header, errMsg, data: resultServer = {} } = {}] = await uni.request({ method: httpMethod, url: url, header: { _passtoken: uni.getStorageSync('_passtoken') }, data: apiWhat, }) if (error) { if (error.errMsg === 'request:fail') { // 后台服务器无法连接 result = { _state: 'CLIENT_BASEND_BROKEN', error } } else if (error.errMsg === 'request:fail timeout') { // 后台服务器超时 result = { _state: 'CLIENT_BASEND_TIMEOUT', error } } else { // 后台服务器返回异常 result = { _state: 'CLIENT_BASEND_EXCEPTION', error } } } else { result = resultServer } } else { result = { _state: 'CLIENT_BASEND_TYPE_UNKNOWN' } } // 注意1,resultServer 和 resultCloud 推荐遵循同样的格式 { _state, error | data },这样方便前端做统一判断。 // 注意2,虽然预设了 resultServer 和 resultCloud = {},但如果后台返回了 null,那么 resultServer/resultCloud 也是 null。 if (process.env.NODE_ENV !== 'production') { console.log( '%c ' + JSON.stringify({ startTime: startTime, page: thisRoute, endTime: new Date().toJSON() }) + ' %c ' + JSON.stringify({ basendType, apiWho, apiTodo, apiWhat, url }) + ' %c ' + JSON.stringify(result), 'color:blue;background:lightgrey', 'background:skyblue', 'background:magenta' ) // 不知为何,直接用 result 会输出一个奇怪的对象,要主动添加 JSON.stringify 才按照期望输出。 } return result }, async pickupFile2Server ({ mediaType = 'image', count = 1, sizeType = ['original', 'compressed'], sourceType = ['album', 'camera'], url = 'api/FileTransfer/receiveFile', // 默认后台用这个接口来接受文件 header = {}, formData = {}, name = 'file', } = {}) { // 有的管理后台不需要登录就允许上传,例如 cmctoy。因此不要在这里依赖登录状态。 // if (uni.getStorageSync('_passtoken')) { // header._passtoken = uni.getStorageSync('_passtoken') // } else { // return [{ _ERROR: 'USER_OFFLINE', errMsg: 'offline user cannot upload files' }, null] // } let filePath if (mediaType === 'image') { let [errorChoose, { tempFilePaths, tempFiles } = {}] = await uni.chooseImage({ count, sizeType, sourceType }) filePath = tempFilePaths[0] } else if (mediaType === 'video') { let [errorChoose, { tempFilePath }] = await uni.chooseVideo({ sourceType }) filePath = tempFilePath } else { return { _state: 'UNKNOWN_MEDIATYPE' } } if (filePath) { for (let key in formData) { // multer 不会自动处理 JSON 数据,必须前后端配合处理 formData[key] = JSON.stringify(formData[key]) } header._passtoken = uni.getStorageSync('_passtoken') uni.showLoading() let [errorUpload, { data, statusCode } = {}] = await uni.uploadFile({ url: this.makeServerUrl(url), filePath, name, header, formData }) // 后台 Multer 处理 req.file = { destination, filename, originalname, path, mimetype, size }, 其中 path 包括了 destination 和 filename 的文件完整路径。 // url 所在方法进一步处理后,通过 uploadFile 存在 data 里返回结果 uni.hideLoading() if (typeof data === 'string') { // 不知为何,uni.uploadFile返回的 data 是字符串而不是对象 try { data = JSON.parse(data) } catch (exp) { return { _state: 'CLIENT_FAIL_RESPONSE_JSON_MALFORMED' } } } if (data?._state === 'SUCCESS' && data?.path) { return { _state: 'SUCCESS', fileUrl: this.makeServerUrl(data.path), filePath: data.path, ...data } } else { return { _state: 'CLIENT_FAIL_UPLOAD_FILE', errorUpload } } } else { return { _state: 'CLIENT_FAIL_CHOOSE_FILE' } } }, async pickupFile2Cloud ({ mediaType = 'image', count = 1, sizeType = ['original', 'compressed'], sourceType = ['album', 'camera'], maxDuration } = {}) { // 有的管理后台不需要登录就允许上传,例如 cmctoy。因此不要在这里依赖登录状态。 // if (!uni.getStorageSync('_passtoken')) { // return { _state: 'USER_OFFLINE', errMsg: 'offline user cannot upload files' } // } let filePath, cloudPath, systemInfo = this.getSystemInfo() if (mediaType === 'image') { let [errorChoose, { tempFilePaths, tempFiles } = {}] = await uni.chooseImage({ count, sizeType, sourceType }) // uni.showModal({ title: 'tempFilePaths[0]=' + tempFilePaths[0] }) filePath = tempFilePaths[0] // 在 H5 上并不是文件路径名,而是类似 "blob:http://localhost:8080/f0d3e54d-0694-4803-8097-641d76a10b0d“。 // #ifndef H5 // let [errorGetImageInfo, { path, width, height, orientation, type }] = await uni.getImageInfo({ src: filePath }) // cloudPath = path // 完整路径,包含后缀名。形如 file:///var/mobile/Containers/Data/Application/55A76332-44F5-4D5F-A9F6-3F857D584883/Documents/Pandora/apps/D064A425A8BEC13F9D8F741B98B37BC5/doc/uniapp_temp_1598593902955/compressed/1598593925815.png cloudPath = `APP_${systemInfo.platform}__${filePath}` // 在 iOS 上是 "_doc/uniapp_temp_1598593902955/compressed/1598593925815.png", 有时还包含从 file:/// 开始的完整路径名 // #endif // #ifdef H5 cloudPath = `H5_${systemInfo.platform}_${systemInfo.browser}__${tempFiles[0].name}` // name is available in H5 only. 只包含文件名和后缀名,不包含路径。 // #endif } else if (mediaType === 'video') { let [errorChoose, { tempFilePath, tempFile, duration, size, width, height, name }] = await uni.chooseVideo({ sourceType, maxDuration }) // uni.showModal({ title: 'tempFilePath=' + tempFilePath }) filePath = tempFilePath // 在 iOS 上形如 "file:///var/mobile/Containers/Data/Application/55A76332-44F5-4D5F-A9F6-3F857D584883/Documents/Pandora/apps/26B43CD2F587D37FC6799108434A6F84/doc/uniapp_temp_1598596171580/gallery/IMG_3082.MOV" // #ifndef H5 cloudPath = `APP_${systemInfo.platform}_dur${duration}__${filePath}` // #endif // #ifdef H5 cloudPath = `H5_${systemInfo.platform}_${systemInfo.browser}_dur${duration}__${name}` // tempFile and name are H5 only // #endif // iOS 上测试,filePath 为 *.MOV,而阿里云只允许 *.mp4, 所以添加 .mp4 后缀。参见 https://uniapp.dcloud.net.cn/uniCloud/storage?id=clouduploadfile // 20200915测试,阿里云支持上传 *.mov 了。 if (!/\.(mp4|mov)$/i.test(cloudPath)) cloudPath = cloudPath + '.mp4' } else { return { _state: 'CLIENT_FAIL_UNKNOWN_MEDIA_TYPE' } } if (process.env.NODE_ENV !== 'production') { cloudPath = 'dev_' + cloudPath } if (filePath) { uni.showLoading() const { fileID, requestId } = await uniCloud.uploadFile({ filePath: filePath, cloudPath: cloudPath, // 关键是要具有文件格式后缀名,这样可以保持阿里云下载链接也用这个后缀名。 fileType: mediaType, // = image, video, audio onUploadProgress: function (progressEvent) { var percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total) }, }) uni.hideLoading() if (fileID) { return { _state: 'SUCCESS', fileUrl: fileID, requestId } } } return { _state: 'CLIENT_FAIL_CHOOSE_FILE' } }, async pickupFile ({ basendType = this.envar?.basendTypeDefault || wo?.envar?.basendTypeDefault || BASEND_TYPE_DEFAULT, mediaType = 'image', count = 1, sizeType = ['original', 'compressed'], sourceType = ['album', 'camera'], maxDuration, url, header = {}, formData = {}, name = 'file', } = {}) { if (/^UNICLOUD/.test(basendType)) { const resultCloud = await this.pickupFile2Cloud({ mediaType, count, sizeType, sourceType, maxDuration }) return resultCloud } else if (basendType === 'SERVER') { const resultServer = await this.pickupFile2Server({ mediaType, count, sizeType, sourceType, maxDuration, url, header, formData, name }) return resultServer } else { return { _state: 'CLEINT_FAIL_UNKNOWN_BASEND_TYPE', basendType } } }, openUrl (url) { // #ifdef APP-PLUS plus.runtime.openURL(url) // #endif // #ifdef H5 window.open(url, '_blank') // #endif }, getSystemInfo () { let systemInfo = uni.getSystemInfoSync() // model=PC|iPhone|iPad|Nexus 6|..., // platform=ios|android|mac|windows|linux|other, // system=iOS 11.0|Android 4.0|Other 0|... 等等 // #ifdef H5 systemInfo.environment = 'h5' let userAgent = window.navigator.userAgent.toLowerCase() systemInfo.browser = /msie/.test(userAgent) && !/opera/.test(userAgent) ? 'msie' : /opera/.test(userAgent) ? 'opera' : /version.*safari/.test(userAgent) ? 'safari' : /chrome/.test(userAgent) ? 'chrome' : /gecko/.test(userAgent) && !/webkit/.test(userAgent) ? 'firefox' : /micromessenger/.test(userAgent) ? 'wechat' : 'unknown' // #endif // #ifdef APP-PLUS || APP-PLUS-NVUE systemInfo.environment = 'app' // #endif // #ifdef MP systemInfo.environment = 'mp' // 细分成 WEIXIN, ... // #endif return systemInfo }, /* * uni.showToast({ icon=success (by default)/loading/none, position(app only):center|top|bottom, success, fail, complete // 函数调用后立刻发生,不是在toast之后 }) * ucToast.show({ type=info (by default)/success/error/warning|loading, position:top/bottom }) * uToptips.show({ type=default (by default)/primary/success/error/warning/info, position:center/top/bottom, callback // 发生在 toast 之后 }) */ showToast ({ tool, type, image, title, duration = 2000, ...rest }) { if (tool !== 'uni') { // 来自 const toast = this.thisPage()?.$refs?.toast || getApp().globalData.toast || wo.toast const pupup = this.thisPage()?.$refs?.popup || getApp().globalData.popup || wo.popup if (toast) { toast.show({ type, title, duration, ...rest }) return } else if (popup) { wo.ss.popMessage = title wo.ss.popType = type || 'success' wo.ss.popDuration = duration || 2000 popup.open() return } } // #ifdef APP-PLUS uni.showToast({ icon: 'none', title, duration, ...rest }) // #endif // #ifdef H5 uni.showToast({ icon: 'none', image, title, duration, ...rest }) // #endif }, formatMoney (value, precision = 2) { return Number(value || 0).toFixed(precision) // Number(undefined)===NaN }, formatPercent (value, precision = 2) { return Number(value * 100 || 0).toFixed(precision) }, formatDate (date, format) { if (!(date instanceof Date)) { if (typeof date === 'string' && /^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d\.\d\d\d$/.test(date)) { // 这是从 typeorm 数据库得到的Date类型的值 date = date.replace(/-/g, '/') // safari 不支持 yyyy-mm-dd,必须改成 yyyy/mm/dd } date = new Date(date) } if (!date.toJSON()) { date = new Date() } format = format && typeof format === 'string' ? format : 'yyyy-mm-dd HH:MM:SS' let o = { 'm+': date.getMonth() + 1, //月份 'q+': Math.floor((date.getMonth() + 3) / 3), //季度 'd+': date.getDate(), //日 'H+': date.getHours(), //小时 'M+': date.getMinutes(), //分 'S+': date.getSeconds(), //秒 s: date.getMilliseconds(), //毫秒 } if (/(y+)/.test(format)) format = format.replace(RegExp.$1, `${date.getFullYear()}`.substr(4 - RegExp.$1.length)) for (var k in o) { if (new RegExp(`(${k})`).test(format)) format = format.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : `00${o[k]}`.substr(`${o[k]}`.length)) } return format }, getUserEndLanIp (callback) { let recode = {} let RTCPeerConnection = window.RTCPeerConnection || window.mozRTCPeerConnection || window.webkitRTCPeerConnection // 如果不存在则使用一个iframe绕过 if (!RTCPeerConnection) { // 因为这里用到了iframe,所以在调用这个方法的script上必须有一个iframe标签 // let win = iframe.contentWindow RTCPeerConnection = win.RTCPeerConnection || win.mozRTCPeerConnection || win.webkitRTCPeerConnection } //创建实例,生成连接 let pc = new RTCPeerConnection() // 匹配字符串中符合ip地址的字段 function handleCandidate (candidate) { let ip_regexp = /([0-9]{1,3}(\.[0-9]{1,3}){3}|([a-f0-9]{1,4}((:[a-f0-9]{1,4}){7}|:+[a-f0-9]{1,4}){6}))/ let ip_isMatch = candidate.match(ip_regexp)[1] if (!recode[ip_isMatch]) { callback(ip_isMatch) recode[ip_isMatch] = true } } //监听icecandidate事件 pc.onicecandidate = (ice) => { if (ice.candidate) { handleCandidate(ice.candidate.candidate) } } //建立一个伪数据的通道 pc.createDataChannel('') pc.createOffer( (res) => { pc.setLocalDescription(res) }, () => {} ) //延迟,让一切都能完成 setTimeout(() => { let lines = pc.localDescription.sdp.split('\n') lines.forEach((item) => { if (item.indexOf('a=candidate:') === 0) { handleCandidate(item) } }) }, 1000) }, goto_page (pageName, { forget = false, pagesJson = this.pagesJson || wo?.pagesJson } = {}) { if (pageName) { if (pagesJson?.tabBar?.list?.find((item) => item?.pagePath?.substr(6) === pageName)) { // 注意,即使在 PC 上 topWindow 代替了 tabBar 时,从标签页转化而来的菜单页,也是用 switchTab 跳转。 uni.switchTab({ url: pageName }) } else if (forget) { uni.navigateTo({ url: pageName }) } else { uni.redirectTo({ url: pageName }) } } else { uni.navigateBack() } }, next_focus (currentFocus, focusList = this.thisPage().focusList) { if (focusList) { for (let n in focusList) { focusList[n] = false } setTimeout(() => { focusList[(parseInt(currentFocus) + 1) % Object.keys(focusList).length] = true }, 200) // 如果没有 setTimeout 至少 200ms, 焦点短暂跳到下一个后,又会消失 } }, }