976 lines
		
	
	
		
			44 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			976 lines
		
	
	
		
			44 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const crypto = require('crypto')
 | ||
| const path = require('path')
 | ||
| 
 | ||
| //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 my = {
 | ||
|   get_mylang () {
 | ||
|     // globalThis.getCurrentPages?.() 在 topWindow/App.vue 里有可能为空,所以用 getApp().$store 更安全. 20230513: 发现在微信小程序模拟器里,getApp().$store.state 未定义,所以还是用 globalThis.wo?.ss
 | ||
|     return globalThis.wo?.ss?.i18n?.mylang || globalThis.getApp?.()?.$store?.state?.i18n?.mylang
 | ||
|   },
 | ||
| }
 | ||
| 
 | ||
| module.exports = {
 | ||
|   BASE_TYPE_DEFAULT: 'SERVER', // one of { SERVER: 服务器, UNICLOUD_FUNC: 云函数, UNICLOUD_OBJECT: 云对象 }
 | ||
| 
 | ||
|   // 用直观的色彩,代替语义化的类型:
 | ||
|   // uView 中大量组件都使用 type 来设置颜色,而 uni-ui 中只有少量组件用到。
 | ||
|   c2t: {
 | ||
|     RED: 'error',
 | ||
|     GREEN: 'success',
 | ||
|     BLUE: 'primary',
 | ||
|     YELLOW: 'warn',
 | ||
|     GREY: 'info',
 | ||
| 
 | ||
|     // uAlertTips: { YELLOW: 'warning', BLUE: 'primary', RED: 'error', GREY: 'info', GREEN: 'success' },
 | ||
|     // uBadge: { RED: 'error', BLUE: 'primary', YELLOW: 'warning', GREY: 'info', GREEN: 'success' },
 | ||
|     // uButton: { WHITE: 'default', BLUE: 'primary', YELLOW: 'warning', RED: 'error', GREY: 'info', GREEN: 'success' },
 | ||
|     // uNoticeBar: { YELLOW: 'warning', BLUE: 'primary', RED: 'error', GREY: 'info', GREEN: 'success', TRANS: 'none' },
 | ||
|     // uTag: { BLUE: 'primary', RED: 'error', YELLOW: 'warning', GREY: 'info', GREEN: 'success' },
 | ||
|     // uToast: { BLACK: 'default', BLUE: 'primary', YELLOW: 'warning', RED: 'error', GREY: 'info', GREEN: 'success' },
 | ||
|     // uTopTips: { BLUE: 'primary', RED: 'error', YELLOW: 'warning', GREY: 'info', GREEN: 'success' },
 | ||
| 
 | ||
|     // uniBadge: { GREY: 'default', YELLOW: 'warning', BLUE: 'primary', GREEN: 'success', RED: 'error' },
 | ||
|     // uniButton: { GREY: 'default', RED: 'warn', RGB: 'primary' },
 | ||
|     // uniPopupDialog: { GREEN: 'success', YELLOW: 'warn', RED: 'error', GREY: 'info' },
 | ||
|     // uniPopupMessage: { GREEN: 'success', YELLOW: 'warn', RED: 'error', GREY: 'info' },
 | ||
|     // uniTag: { GREY: 'default', YELLOW: 'warning', BLUE: 'primary', GREEN: 'success', RED: 'error', PURPLE: 'royal' },
 | ||
|   },
 | ||
| 
 | ||
|   is_text_file (fileName = '') {
 | ||
|     const ext = /\./.test(fileName) ? fileName.split('.').pop().toLowerCase() : ''
 | ||
|     return wo.envar.textExtensionList?.includes?.(ext)
 | ||
|   },
 | ||
|   is_image_file (fileName = '') {
 | ||
|     const ext = /\./.test(fileName) ? fileName.split('.').pop().toLowerCase() : ''
 | ||
|     return wo.envar.imageExtensionList?.includes?.(ext)
 | ||
|   },
 | ||
|   is_video_file (fileName = '') {
 | ||
|     const ext = /\./.test(fileName) ? fileName.split('.').pop().toLowerCase() : ''
 | ||
|     return wo.envar.videoExtensionList?.includes?.(ext)
 | ||
|   },
 | ||
|   is_audio_file (fileName = '') {
 | ||
|     const ext = /\./.test(fileName) ? fileName.split('.').pop().toLowerCase() : ''
 | ||
|     return wo.envar.audioExtensionList?.includes?.(ext)
 | ||
|   },
 | ||
| 
 | ||
|   thisPage () {
 | ||
|     return this.__page__
 | ||
|       ? this // constructor.name==='VueComponent' 只在 development 环境有用,在 production 环境会被简化成 'o'。
 | ||
|       : globalThis.getCurrentPages?.()?.pop?.() || {} // [20220401] 发现在 topWindow 里,或者在 App.vue 里, getCurrentPages() 是 undefined 和 空数组 [],因此在这里默认 {} 做保护。
 | ||
|   },
 | ||
| 
 | ||
|   localizeText (i18nText, { langCode, precise = false } = {}) {
 | ||
|     i18nText =
 | ||
|       i18nText || // 如果传入i18n参数 ({zhCN:'...', enUS:'...'})
 | ||
|       this.i18nText || // 1) 如果挂载到具体页面的 computed { lote: wo?.localizeText } 那么 this 就是当前页面,直接取用 this.i18nText 即可。2) 对于组件内定义的 i18nText,要使用 this 来获得组件内的 i18nText
 | ||
|       getCurrentPages()?.pop()?.i18nText // 如果不是挂载到 Vue.prototype 而是 挂载到 wo 下调用,那么 this.i18nText 就不存在了。因此通过 pageNow.i18nText 访问。
 | ||
|     if (['string', 'number', 'boolean'].includes(typeof i18nText)) {
 | ||
|       // 必须先检测是否标量值,如果直接返回 i18nText 可能返回{}等,导致依赖于返回空值的前端出错
 | ||
|       return i18nText
 | ||
|     } else if (typeof i18nText === 'object' && i18nText) {
 | ||
|       return (
 | ||
|         i18nText[langCode || my.get_mylang()] ||
 | ||
|         (precise ? '' : i18nText.earTH || i18nText.gloBAL || i18nText.defLAN || i18nText.enUS || Object.values(i18nText)[0] || '')
 | ||
|       )
 | ||
|     } else {
 | ||
|       return ''
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   localeText () {
 | ||
|     // 专供绑定到 computed { lote: wo?.localeText } 使用,这时 this 就是当前页面。
 | ||
|     return this.i18nText?.[my.get_mylang()] || {}
 | ||
|   },
 | ||
| 
 | ||
|   setBarTitles ({ windowTitle, pageTitle, wo = globalThis.wo } = {}) {
 | ||
|     const langNow = my.get_mylang()
 | ||
|     const pageNow = globalThis.getCurrentPages?.()?.pop?.()
 | ||
|     // 在ios/android app里,pageNow.route 是正确的,但是 pageNow.xxx 等自定义属性 都 undefined,必须 pageNow.$vm.$data.xxx 才可以。
 | ||
|     // 注意,$vm.$data 不包括 computed 属性,而 pageNow 里包括。因此不能把 i18nPageTitle 放在 computed 里。
 | ||
| 
 | ||
|     const navibarTitle = this.localizeText(
 | ||
|       pageTitle ||
 | ||
|         pageNow?.$vm?.$data?.i18nPageTitle || // 页面.vue 的 i18nPageTitle 变量
 | ||
|         pageNow?.$vm?.$data?.i18nText?.[langNow]?.tPageTitle || // 页面.vue 的 i18nText 对象
 | ||
|         pageNow?.$vm?.$data?.pageTitle ||
 | ||
|         wo?.pageSet?.[pageNow?.route?.substring?.(6)]?.i18nPageTitle ||
 | ||
|         wo?.pagesJson?.pages?.find((page) => page.path === pageNow?.route)?.i18nPageTitle || // pages.json 的页面配置里
 | ||
|         ''
 | ||
|     )
 | ||
| 
 | ||
|     windowTitle =
 | ||
|       windowTitle ||
 | ||
|       this.localizeText(wo?.envar?.callnames, { langCode: langNow }) ||
 | ||
|       this.localizeText(wo?.pagesJson?.appInfo?.i18nText, { langCode: langNow }) ||
 | ||
|       wo?.pagesJson?.globalStyle?.navigationBarTitleText ||
 | ||
|       ''
 | ||
| 
 | ||
|     if (wo.envar._clientInfo.deviceType === 'pc') {
 | ||
|       uni.setNavigationBarTitle({ title: windowTitle + (navibarTitle ? ` - ${navibarTitle}` : '') })
 | ||
|     } else {
 | ||
|       uni.setNavigationBarTitle({ title: navibarTitle })
 | ||
|     }
 | ||
| 
 | ||
|     //#ifdef WEB
 | ||
|     //// 设置窗口标题栏 document.title
 | ||
|     //// navibarTitle 也会被用于浏览器的标签标题,可用 document.title 去覆盖。必须放在 setNavigationBarTitle 之后。
 | ||
|     //// 但这个方案,在电脑上,还是会显示 navibarTitle 在浏览器窗口顶栏,不知为何。
 | ||
|     if (wo.envar._clientInfo.deviceType !== 'pc' && /MicroMessenger/i.test(globalThis.window?.navigator?.userAgent)) {
 | ||
|       //// 微信浏览器里,本身就显示了标题栏,和自有的导航栏形成功能重叠和混淆。
 | ||
|       //// 设置标题栏为空或覆盖
 | ||
|       document.title = windowTitle
 | ||
|       //// 或者设置导航栏隐藏。但这样导致,用户容易误点微信浏览器标题栏的 X 关掉页面,所以还是显示导航栏吧。
 | ||
|       // document.getElementsByTagName('uni-page-head')?.[0]?.remove() // 或者 [0]?.style?.display = 'none'
 | ||
|     }
 | ||
|     //#endif
 | ||
| 
 | ||
|     if (wo.envar._clientInfo.deviceType === 'pc') {
 | ||
|       uni.hideTabBar()
 | ||
|     } else {
 | ||
|       // 必须要在有 tab 的页面里 setTabBarItem 才有效果
 | ||
|       //const midIndex = parseInt(wo?.pagesJson?.tabBar?.list?.length/2) // 如果存在midButton,实际上tabBar.list.length必须为偶数。不过为了心安,再parseInt一下。
 | ||
|       wo?.pagesJson?.tabBar?.list?.forEach((tab, tabIndex) => {
 | ||
|         if (tab.i18nText && tab.i18nText[langNow]) {
 | ||
|           uni.setTabBarItem({
 | ||
|             // #ifdef WEB
 | ||
|             index: tabIndex, // + ((wo?.pagesJson?.tabBar?.midButton?.iconPath && tabIndex >= midIndex)?1:0), // H5 里,如果使用了 midButton,tabBarItem的index出现错位,需hack调整。推测,在H5里 midButton 作为一个普通tab被插入到 tabBar 里,导致 tabBar 的 index 和 wo?.pagesJson.tabBar.list 的 index 错位了。[20211031] 注意到,从 HBuilderX 3.2.12.20211029 起,在 H5 里也没有错位了。
 | ||
|             // #endif
 | ||
|             // #ifndef WEB
 | ||
|             index: tabIndex,
 | ||
|             // #endif
 | ||
|             text: tab.i18nText[langNow],
 | ||
|           })
 | ||
|         }
 | ||
|       })
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   make_server_url (route, { envar = globalThis.wo?.envar, fullUrl = false } = {}) {
 | ||
|     if (typeof route === 'string') route = route.replace('\\', '/')
 | ||
|     else if (route?.apiWho && route?.apiTodo) {
 | ||
|       const { apiVersion = 'api', apiWho, apiTodo } = route
 | ||
|       route = `${apiVersion}/${apiWho}/${apiTodo}`
 | ||
|     } else {
 | ||
|       // 防止 route 为 null, undefined 等由于后台数据库默认值而造成的异常。
 | ||
|       route = ''
 | ||
|     }
 | ||
|     // 已是完整url
 | ||
|     if (/^https?:\/\//.test(route)) {
 | ||
|       return route
 | ||
|     }
 | ||
|     // 本地图片
 | ||
|     if (/^\/static\//.test(route)) {
 | ||
|       return route
 | ||
|     }
 | ||
|     route = route.replace(/^\//, '')
 | ||
|     // 对 route='abc.com' 这种,应当有 fullUrl===true,就直接使用,而不是组装成 pexserver.tic.cc/abc.com
 | ||
|     // 纯数字和字母的cid
 | ||
|     if (/^[0-9a-zA-Z]+$/.test(route) && envar?.ipfsLens) {
 | ||
|       return `${envar.ipfsLens.replace(/\/$/, '')}/${route}`
 | ||
|     }
 | ||
|     //// base url / 后台服务器url 需要组装。包括了 route === '_filestore/xxx' 的情况
 | ||
|     // 已有现成后端服务域名
 | ||
|     if (fullUrl) {
 | ||
|       return `http://${route}`
 | ||
|     } else if (envar?.servUrl) {
 | ||
|       return `${envar.servUrl.replace(/\/$/, '')}/${route}`
 | ||
|     } else if (envar?.servHostname) {
 | ||
|       // 需要组装后端服务域名
 | ||
|       const hostname = envar.servHostname /*|| globalThis.window?.location?.hostname*/ || 'localhost'
 | ||
|       const port = envar.servPort /*|| globalThis.window?.location?.port*/ || ''
 | ||
|       const protocol = hostname === 'localhost' ? 'http' : envar.servProtocol || (process.env.NODE_ENV === 'production' ? 'https' : 'http')
 | ||
|       return `${protocol}://${hostname}${port ? ':' : ''}${port}/${route}`
 | ||
|     }
 | ||
|     return route
 | ||
|   },
 | ||
| 
 | ||
|   make_bgurl (image) {
 | ||
|     return image ? `url(${this.make_server_url(image)})` : ''
 | ||
|   },
 | ||
| 
 | ||
|   /** 统一 uni.request 和 uniCloud.callFunction 的调用方法,提供统一、透明的后台调用
 | ||
|    * 返回值:{ _state, 成功结果或错误结果 },其中 _state 除了后台返回的,还可以是
 | ||
|    * - CLIENT_WOBASE_BROKEN: 前端发现后台断线
 | ||
|    * - CLIENT_WOBASE_TIMEOUT: 前端发现后台超时
 | ||
|    * - CLINET_WOBASE_EXCEPTION: 前端发现后台异常
 | ||
|    **/
 | ||
|   async callBase ({
 | ||
|     baseType = globalThis.wo?.envar?.baseTypeDefault || this.BASE_TYPE_DEFAULT,
 | ||
|     httpMethod = 'POST',
 | ||
|     apiVersion = 'api',
 | ||
|     apiWho,
 | ||
|     apiTodo,
 | ||
|     apiWhat = {},
 | ||
|     timeout,
 | ||
|   }) {
 | ||
|     const thisRoute = globalThis.getCurrentPages?.()?.pop?.()?.route || 'VueApp' // 立刻保存 route,因为在调用后台后,可能已切换到了其他页面。
 | ||
|     const startTime = new Date().toJSON()
 | ||
|     let apiurl = undefined
 | ||
|     apiWhat._clientInfo = {
 | ||
|       ...globalThis.wo?.envar?._clientInfo,
 | ||
|       lang: globalThis.wo?.ss?.i18n?.mylang,
 | ||
|       // #ifdef WEB
 | ||
|       requrl: globalThis.location?.href,
 | ||
|       // #endif
 | ||
|     }
 | ||
|     apiWhat._passtoken = uni.getStorageSync('_passtoken')
 | ||
|     if (process.env.NODE_ENV !== 'production') {
 | ||
|       console.log(
 | ||
|         {
 | ||
|           _at: startTime,
 | ||
|           _io: 'CREQ',
 | ||
|           _path: `${thisRoute} => ${apiWho}/${apiTodo}`,
 | ||
|           apiurl,
 | ||
|           baseType,
 | ||
|           apiWhat,
 | ||
|           timeout,
 | ||
|         }
 | ||
|         // `%c ${startTime} (IN) ${thisRoute} :: ${apiTodo}`,
 | ||
|         // 'background: #87cefa; border-radius: 0.5em;color: white; font-weight: bold; padding: 2px 0.5em;',
 | ||
|         // {
 | ||
|         //   baseType,
 | ||
|         //   apiWho,
 | ||
|         //   apiTodo,
 | ||
|         //   apiWhat,
 | ||
|         //   timeout,
 | ||
|         //   apiurl,
 | ||
|         // }
 | ||
|       )
 | ||
|     }
 | ||
|     let result = {}
 | ||
|     if (baseType === 'UNICLOUD_OBJECT') {
 | ||
|       const uniObj = uniCloud.importObject(apiWho)
 | ||
|       try {
 | ||
|         result = await uniObj[apiTodo](apiWhat)
 | ||
|       } catch (error) {
 | ||
|         result = { _state: 'CLINET_WOBASE_EXCEPTION', error }
 | ||
|       }
 | ||
|     } else if (baseType === 'UNICLOUD_FUNC') {
 | ||
|       let { /* success, header, requestedId, */ result: resultCloud = {} } = await uniCloud
 | ||
|         .callFunction({
 | ||
|           name: apiWho,
 | ||
|           data: {
 | ||
|             apiTodo,
 | ||
|             apiWhat,
 | ||
|             // uniIdToken // uniCloud自动getStorageSync('uni_id_token')并传递为 uniIdToken;也可自行组装传入 uniIdToken
 | ||
|           },
 | ||
|         })
 | ||
|         .catch((error) => {
 | ||
|           // {errMsg, stack} = error
 | ||
|           if (/request:fail/.test(error.errMsg)) {
 | ||
|             // 后台云服务无法连接
 | ||
|             return { _state: 'CLIENT_WOBASE_BROKEN', error }
 | ||
|           } else {
 | ||
|             // 后台云服务返回异常
 | ||
|             return { _state: 'CLIENT_WOBASE_EXCEPTION', error }
 | ||
|           }
 | ||
|         })
 | ||
|       result = resultCloud
 | ||
|     } else if (baseType === 'SERVER') {
 | ||
|       if (httpMethod === 'GET') {
 | ||
|         // 如果不是 POST 方法,要额外把参数JSON化
 | ||
|         for (let key in apiWhat) {
 | ||
|           apiWhat[key] = JSON.stringify(apiWhat[key])
 | ||
|         }
 | ||
|       }
 | ||
|       apiurl = this.make_server_url(`${apiVersion}/${apiWho}/${apiTodo}`)
 | ||
|       let [error, { statusCode, header, errMsg, data: resultServer = {} } = {}] = await uni
 | ||
|         .request({
 | ||
|           method: httpMethod,
 | ||
|           url: apiurl,
 | ||
|           data: apiWhat,
 | ||
|           timeout,
 | ||
|         })
 | ||
|         .catch((expt) => {
 | ||
|           return [undefined, { data: { _state: 'CLIENT_WOBASE_EXCEPTION' } }]
 | ||
|         })
 | ||
|       if (error) {
 | ||
|         if (error.errMsg === 'request:fail') {
 | ||
|           // 后台服务器无法连接
 | ||
|           result = { _state: 'CLIENT_WOBASE_BROKEN', error }
 | ||
|         } else if (error.errMsg === 'request:fail timeout') {
 | ||
|           // 后台服务器超时
 | ||
|           result = { _state: 'CLIENT_WOBASE_TIMEOUT', error }
 | ||
|         } else {
 | ||
|           // 后台服务器返回异常
 | ||
|           result = { _state: 'CLIENT_WOBASE_EXCEPTION', error }
 | ||
|         }
 | ||
|       } else {
 | ||
|         result = resultServer
 | ||
|       }
 | ||
|     } else {
 | ||
|       result = { _state: 'CLIENT_WOBASE_TYPE_UNKNOWN' }
 | ||
|     }
 | ||
| 
 | ||
|     if (result?._passtoken) {
 | ||
|       uni.setStorageSync('_passtoken', result._passtoken)
 | ||
|     }
 | ||
|     if (result?.uni_id_token) {
 | ||
|       uni.setStorageSync('uni_id_token', result.uni_id_token)
 | ||
|     }
 | ||
|     if (result?.clid) {
 | ||
|       uni.setStorageSync('clid', result.clid)
 | ||
|     }
 | ||
| 
 | ||
|     // 注意1,resultServer 和 resultCloud 推荐遵循同样的格式 { _state, error | data },这样方便前端做统一判断。
 | ||
|     // 注意2,虽然预设了 resultServer 和 resultCloud = {},但如果后台返回了 null,那么 resultServer/resultCloud 也是 null。
 | ||
|     if (process.env.NODE_ENV !== 'production') {
 | ||
|       console.log(
 | ||
|         {
 | ||
|           _at: new Date().toJSON(),
 | ||
|           _io: 'CRES',
 | ||
|           _path: `${thisRoute} => ${apiWho}/${apiTodo}`,
 | ||
|           result,
 | ||
|         }
 | ||
|         // `%c ${new Date().toJSON()} (OUT) ${thisRoute} :: ${apiTodo}`,
 | ||
|         // 'background: #4169e1; border-radius: 0.5em;color: white; font-weight: bold; padding: 2px 0.5em;',
 | ||
|         // result
 | ||
|       )
 | ||
|     }
 | ||
|     return result
 | ||
|   },
 | ||
| 
 | ||
|   async pickupFile2Server ({
 | ||
|     fileDragged, // 形如 { filePath: "blob:http://localhost:8080/218e3007-f74b-440d-a2db-8f1d9aed746f", name: 'abc.txt', size: 2375, type: "text/plain", lastModified: 1732291306772 }
 | ||
|     mediaType = 'image', // could be: image, video, array of supported extensions, anything else
 | ||
|     count = 1,
 | ||
|     sizeType = ['original', 'compressed'],
 | ||
|     sourceType = ['album', 'camera'],
 | ||
|     url = 'api/Fileloader/receiveFile', // 默认后台用这个接口来接受文件
 | ||
|     header = {},
 | ||
|     formData = {},
 | ||
|     name = 'file',
 | ||
|   } = {}) {
 | ||
|     // 有的管理后台不需要登录就允许上传,例如 cmctoy。因此不要在这里依赖登录状态。
 | ||
| 
 | ||
|     let filePath, fileSize, filePicked
 | ||
|     if ('undefined' !== typeof fileDragged?.size) {
 | ||
|       // size 为 0 的时候也该要做这些处理
 | ||
|       fileSize = fileDragged.size
 | ||
|       filePath = fileDragged.filePath
 | ||
|       filePicked = fileDragged
 | ||
|       if (!mediaType) {
 | ||
|         mediaType = this.is_image_file(fileDragged.name)
 | ||
|           ? 'image'
 | ||
|           : this.is_video_file(fileDragged.name)
 | ||
|           ? 'video'
 | ||
|           : this.is_audio_file(fileDragged.name)
 | ||
|           ? 'audio'
 | ||
|           : 'file'
 | ||
|       }
 | ||
|     } else if (mediaType === 'image') {
 | ||
|       let [errorChoose, { tempFilePaths, tempFiles } = {}] = await uni.chooseImage({ count, sizeType, sourceType })
 | ||
|       if (errorChoose) {
 | ||
|         return {
 | ||
|           _state: 'CER_FAIL_CHOOSE',
 | ||
|           _msg: '', // { zhCN: '图像选择失败。请稍后再试,或向客服投诉。', enUS: 'Image choose failed. Please try again later, or report to customer service.' },
 | ||
|         }
 | ||
|       }
 | ||
|       fileSize = tempFiles?.[0]?.size
 | ||
|       filePicked = tempFiles?.[0]
 | ||
|       filePath = tempFilePaths?.[0]
 | ||
|     } else if (mediaType === 'video') {
 | ||
|       let [errorChoose, { tempFilePath, tempFile, size, duration }] = await uni.chooseVideo({ sourceType })
 | ||
|       if (errorChoose) {
 | ||
|         return {
 | ||
|           _state: 'CER_FAIL_CHOOSE',
 | ||
|           _msg: '', // { zhCN: '视频选择失败。请稍后再试,或向客服投诉。', enUS: 'Video choose failed. Please try again later, or report to customer service.' },
 | ||
|         }
 | ||
|       }
 | ||
|       fileSize = size
 | ||
|       filePicked = tempFile
 | ||
|       filePath = tempFilePath
 | ||
|     } else if (mediaType === 'audio') {
 | ||
|       // #ifdef WEB
 | ||
|       // https://uniapp.dcloud.net.cn/api/media/file.html
 | ||
|       let [errorChoose, { tempFilePaths, tempFiles } = {}] = await uni.chooseFile({
 | ||
|         count,
 | ||
|         extension: wo.envar.audioExtensionList,
 | ||
|         type: undefined,
 | ||
|       }) // 20240429 但是测试下来 extension 参数无效
 | ||
|       if (errorChoose) {
 | ||
|         return {
 | ||
|           _state: 'CER_FAIL_CHOOSE',
 | ||
|           _msg: '', // { zhCN: '文件选择失败。请稍后再试,或向客服投诉。', enUS: 'File choose failed. Please try again later, or report to customer service.' },
 | ||
|         }
 | ||
|       }
 | ||
|       fileSize = tempFiles?.[0]?.size
 | ||
|       filePicked = tempFiles?.[0]
 | ||
|       filePath = tempFilePaths?.[0]
 | ||
|       // #endif
 | ||
|       // #ifndef WEB
 | ||
|       return { _state: 'UNSUPPORTED_FILETYPE', _msg: { zhCN: '请切换到网页端上传文件!', enUS: 'Please switch to WebApp to upload files.' } }
 | ||
|       // #endif
 | ||
|     } else {
 | ||
|       // #ifdef WEB
 | ||
|       // https://uniapp.dcloud.net.cn/api/media/file.html
 | ||
|       let [errorChoose, { tempFilePaths, tempFiles } = {}] = await uni.chooseFile({
 | ||
|         count,
 | ||
|         extension: Array.isArray(mediaType) ? mediaType : undefined,
 | ||
|         type: Array.isArray(mediaType) ? undefined : 'all',
 | ||
|       }) // 20240429 但是测试下来 extension 参数无效
 | ||
|       if (errorChoose) {
 | ||
|         return {
 | ||
|           _state: 'CER_FAIL_CHOOSE',
 | ||
|           _msg: '', // { zhCN: '文件选择失败。请稍后再试,或向客服投诉。', enUS: 'File choose failed. Please try again later, or report to customer service.' },
 | ||
|         }
 | ||
|       }
 | ||
|       fileSize = tempFiles?.[0]?.size
 | ||
|       filePicked = tempFiles?.[0]
 | ||
|       filePath = tempFilePaths?.[0]
 | ||
|       // #endif
 | ||
|       // #ifndef WEB
 | ||
|       return { _state: 'UNSUPPORTED_FILETYPE', _msg: { zhCN: '请切换到网页端上传文件!', enUS: 'Please switch to WebApp to upload files.' } }
 | ||
|       // #endif
 | ||
|     }
 | ||
| 
 | ||
|     const fileName = filePicked?.name || filePath?.split?.('/')?.pop?.() // filePicked.name is available in WEB only. on the other hand, filePath 在 WEB 上并不是文件路径名,而是类似 "blob:http://localhost:8080/f0d3e54d-0694-4803-8097-641d76a10b0d“。在 iOS 上是 "_doc/uniapp_temp_1598593902955/compressed/1598593925815.png", 有时还包含从 file:/// 开始的完整路径名。
 | ||
|     if (!fileSize) {
 | ||
|       return {
 | ||
|         _state: 'CER_EMPTY_FILE',
 | ||
|         _msg: { zhCN: '文件为空,无法上传:\n' + fileName, enUS: 'Empty files cannot be uploaded:\n' + fileName },
 | ||
|       }
 | ||
|     } else if (fileSize > (globalThis.wo?.envar?.fileSizeLimit || 10485760)) {
 | ||
|       let sizeLimitMB = parseInt((globalThis.wo?.envar?.fileSizeLimit || 10485760) / 1048576) + 'MB'
 | ||
|       return {
 | ||
|         _state: 'CER_FILE_TOO_LARGE',
 | ||
|         _msg: { zhCN: `文件大于 ${sizeLimitMB},无法上传`, enUS: `The file exceeds ${sizeLimitMB} and cannot be uploaded` },
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     if (
 | ||
|       // #ifndef APP
 | ||
|       // 20240830 luk: 在 App 上,就相信 iOS/Android,不检查文件后缀名。
 | ||
|       (mediaType === 'image' && !this.is_image_file(fileName)) ||
 | ||
|       (mediaType === 'video' && !this.is_video_file(fileName)) ||
 | ||
|       // #endif
 | ||
|       (Array.isArray(mediaType) && !mediaType?.includes?.(/\./.test(fileName) ? '.' + fileName?.split?.('.')?.pop?.()?.toLowerCase?.() : ''))
 | ||
|     ) {
 | ||
|       return { _state: 'UNSUPPORTED_FILETYPE', _msg: { zhCN: '不支持的文件类型:\n' + fileName, enUS: 'Unsupported file type:\n' + fileName } }
 | ||
|     }
 | ||
| 
 | ||
|     if (!filePath) {
 | ||
|       return {
 | ||
|         _state: 'CER_FAIL_CHOOSE',
 | ||
|         _msg: '', // { zhCN: '文件选择失败。请稍后再试,或向客服投诉。', enUS: 'File choose failed. Please try again later, or report to customer service.' },
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     for (let key in formData) {
 | ||
|       // multer 不会自动处理 JSON 数据,必须前后端配合处理
 | ||
|       formData[key] = JSON.stringify(formData[key])
 | ||
|     }
 | ||
|     // 在 Fileloader/fileloader.js 里,已经不再依赖 _passtoken,而且 header 在被 nginx 或 cloudflare (没搞清楚是谁干的)代理之后也被过滤掉了,因此不再使用这一句: header._passtoken = uni.getStorageSync('_passtoken')
 | ||
|     formData._passtoken = uni.getStorageSync('_passtoken') // 20230527 加回这一句,让后台可以根据验证用户来决定怎样处理文件。
 | ||
|     formData.filenameOriginal = fileName // 20241020 不知为何,通过拖拽的文件上传后,后台得到的 file.originalname 形如 "file-1729354713176" 而不是真正的原始文件名,也不包含后缀。为了保持一致,这里主动把真实的文件名(不管是拖拽还是点击的)发给后台来处理。
 | ||
| 
 | ||
|     this.showLoading({ title: { zhCN: '上传中...', enUS: 'Uploading...' } })
 | ||
|     let [errorUpload, { data, statusCode } = {}] = await uni.uploadFile({ url: this.make_server_url(url), filePath, name, header, formData })
 | ||
|     // 后台 Multer 处理 req.file = { destination, filename, originalname, path, mimetype, size }, 其中 path 包括了 destination 和 filename 的文件相对路径。
 | ||
|     // url 指向的后台方法进一步处理后,通过 uni.uploadFile 存在 data 里返回结果: { ...file, cid?, ipfsUrl?, baseUrl? }
 | ||
|     this.hideLoading()
 | ||
| 
 | ||
|     if (typeof data === 'string') {
 | ||
|       // 不知为何,uni.uploadFile返回的 data 是字符串而不是对象
 | ||
|       try {
 | ||
|         data = JSON.parse(data)
 | ||
|         // 注释下面几句,改由后台来处理 filenameOriginal 以把正确的 data.originalname 返回给前端
 | ||
|         // if (fileDragged?.name) {
 | ||
|         //   // 20241020 不知为何,通过拖拽的文件上传后,后台得到的 以及返回的 data.originalname 形如 "file-1729354713176" 而不是真正的原始文件名,也不包含后缀。为了保持一致,这里强行重新设置
 | ||
|         //   data.originalnameSystem = data.originalname
 | ||
|         //   data.originalname = fileDragged.name
 | ||
|         // }
 | ||
|       } catch (exp) {
 | ||
|         return {
 | ||
|           _state: 'BER_FAIL_RESPONSE_JSON_MALFORMED',
 | ||
|           _msg: { zhCN: '文件上传失败。请稍后再试,或向客服报告。', enUS: 'File upload failed. Please try again, or report to customer service.' },
 | ||
|         }
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     if (data?._state === 'SUCCESS' && data?.path) {
 | ||
|       // 后台送来的 baseUrl 在开发环境下,不一定符合前端实际,因为后台只知道预设的 servUrl 例如 https://pexserver.test.tic.cc:7739/... ,而开发环境下实际上是 http://localhost:7739/... 所以再设置一个 fileUrl 来根据 location.origin 调整。// todo: 改名叫 clientUrl 或 userUrl 或 baseUrl4Client,与 baseUrl 对应
 | ||
|       return { _state: 'SUCCESS', fileUrl: this.make_server_url(data.path), ...data } // { path, destination, filename, fileUrl, cid?, ipfsUrl?, baseUrl?, ...file } 注意,data.path 不包含起头的 '/'
 | ||
|     } else {
 | ||
|       return {
 | ||
|         _state: 'BER_FAIL_UPLOAD_FILE',
 | ||
|         _msg: { zhCN: '文件上传失败。请稍后再试,或向客服报告。', enUS: 'File upload failed. Please try again, or report to customer service.' },
 | ||
|         error: errorUpload,
 | ||
|       }
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   async pickupFile2Cloud ({
 | ||
|     fileDragged,
 | ||
|     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, fileSize, filePicked
 | ||
|     let random = crypto.randomBytes(16).toString('hex')
 | ||
|     if ('undefined' !== typeof fileDragged?.size) {
 | ||
|       fileSize = fileDragged.size
 | ||
|       filePath = fileDragged.filePath
 | ||
|       filePicked = fileDragged
 | ||
|       cloudPath = `WEB_${wo.envar._clientInfo.osName}_${random}_${path.extname(fileDragged.name || { image: '.jpg', video: '.mp4' }[mediaType] || '')}` // tempFile and name are H5 only
 | ||
|     } else if (mediaType === 'image') {
 | ||
|       let [errorChoose, { tempFilePaths, tempFiles } = {}] = await uni.chooseImage({ count, sizeType, sourceType })
 | ||
|       if (errorChoose) {
 | ||
|         return {
 | ||
|           _state: 'CER_FAIL_CHOOSE',
 | ||
|           _msg: '', // { zhCN: '图像选择失败。请稍后再试,或向客服投诉。', enUS: 'Image choose failed. Please try again later, or report to customer service.' },
 | ||
|         }
 | ||
|       }
 | ||
|       fileSize = tempFiles?.[0]?.size
 | ||
|       filePicked = tempFiles?.[0]
 | ||
|       filePath = tempFilePaths?.[0] // 在 H5 上并不是文件路径名,而是类似 "blob:http://localhost:8080/f0d3e54d-0694-4803-8097-641d76a10b0d“。// 在 iOS 上是 "_doc/uniapp_temp_1598593902955/compressed/1598593925815.png", 有时还包含从 file:/// 开始的完整路径名
 | ||
|       // #ifndef WEB
 | ||
|       // 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_${wo.envar._clientInfo.osName}_${random}${path.extname(filePath || '.jpg')}`
 | ||
|       // #endif
 | ||
|       // #ifdef WEB
 | ||
|       cloudPath = `WEB_${wo.envar._clientInfo.osName}_${random}${path.extname(tempFiles?.[0]?.name || '.jpg')}` // 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 })
 | ||
|       if (errorChoose) {
 | ||
|         return {
 | ||
|           _state: 'CER_FAIL_CHOOSE',
 | ||
|           _msg: '', // { zhCN: '视频选择失败。请稍后再试,或向客服投诉。', enUS: 'Video choose failed. Please try again later, or report to customer service.' },
 | ||
|         }
 | ||
|       }
 | ||
|       fileSize = size
 | ||
|       filePicked = tempFile
 | ||
|       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 WEB
 | ||
|       cloudPath = `APP_${wo.envar._clientInfo.osName}_${random}_dur${duration}${path.extname(filePath || '.mp4')}`
 | ||
|       // #endif
 | ||
|       // #ifdef WEB
 | ||
|       cloudPath = `WEB_${wo.envar._clientInfo.osName}_${random}_dur${duration}${path.extname(name || '.mp4')}` // tempFile and name are H5 only
 | ||
|       // #endif
 | ||
|       // iOS 上测试,filePath 为 *.MOV,而阿里云只允许 *.mp4, 所以默认添加 .mp4 后缀。参见 https://uniapp.dcloud.net.cn/uniCloud/storage?id=clouduploadfile
 | ||
|       // 20200915测试,阿里云支持上传 *.mov 了。
 | ||
|     } else {
 | ||
|       // #ifdef WEB
 | ||
|       // https://uniapp.dcloud.net.cn/uniCloud/storage.html#uploadfile
 | ||
|       let { errMsg, tempFilePaths, tempFiles } = await uniCloud.chooseAndUploadFile({
 | ||
|         type: Array.isArray(mediaType) ? undefined : 'all', // valid for H5 only
 | ||
|         extension: Array.isArray(mediaType) ? mediaType : undefined,
 | ||
|         count: 1,
 | ||
|         // extention: [],
 | ||
|         // onChooseFile: ({ errMsg, tempFilePaths, tempFiles }) => { },
 | ||
|         // onUploadProgress: ({ index, loaded, total, tempFilePath, tempFile }) => { }
 | ||
|       })
 | ||
|       if (errMsg !== 'chooseAndUploadFile:ok') {
 | ||
|         return {
 | ||
|           _state: 'CER_FAIL_CHOOSE',
 | ||
|           _msg: '', // { zhCN: '文件选择失败。请稍后再试,或向客服投诉。', enUS: 'File choose failed. Please try again later, or report to customer service.' },
 | ||
|         }
 | ||
|       }
 | ||
|       fileSize = tempFiles?.[0]?.size
 | ||
|       filePicked = tempFiles?.[0]
 | ||
|       filePath = tempFilePaths?.[0]
 | ||
|       cloudPath = `WEB_${wo.envar._clientInfo.osName}_${random}${path.extname(tempFiles?.[0]?.name || '')}` // name is available in H5 only. 只包含文件名和后缀名,不包含路径。
 | ||
|       // #endif
 | ||
|       // #ifndef WEB
 | ||
|       return { _state: 'UNSUPPORTED_FILETYPE', _msg: { zhCN: '请切换到网页端上传文件!', enUS: 'Please switch to WebApp to upload files.' } }
 | ||
|       // #endif
 | ||
|     }
 | ||
| 
 | ||
|     const fileName = filePicked?.name || filePath?.split?.('/')?.pop?.() // filePicked.name is available in WEB only. on the other hand, filePath 在 WEB 上并不是文件路径名,而是类似 "blob:http://localhost:8080/f0d3e54d-0694-4803-8097-641d76a10b0d“。在 iOS 上是 "_doc/uniapp_temp_1598593902955/compressed/1598593925815.png", 有时还包含从 file:/// 开始的完整路径名。
 | ||
| 
 | ||
|     if (!fileSize) {
 | ||
|       return { _state: 'CER_EMPTY_FILE', _msg: { zhCN: '文件为空,无法上传:\n' + fileName, enUS: 'Empty files cannot be uploaded:\n' + fileName } }
 | ||
|     } else if (fileSize > (globalThis.wo?.envar?.fileSizeLimit || 10485760)) {
 | ||
|       let sizeLimitMB = parseInt((globalThis.wo?.envar?.fileSizeLimit || 10485760) / 1048576) + 'MB'
 | ||
|       return {
 | ||
|         _state: 'CER_FILE_TOO_LARGE',
 | ||
|         _msg: { zhCN: `文件大于 ${sizeLimitMB},无法上传`, enUS: `The file exceeds ${sizeLimitMB} and cannot be uploaded` },
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     if (
 | ||
|       // #ifndef APP
 | ||
|       // 20240830 luk: 在 App 上,就相信 iOS/Android,不检查文件后缀名。
 | ||
|       (mediaType === 'image' && !this.is_image_file(fileName)) ||
 | ||
|       (mediaType === 'video' && !this.is_video_file(fileName)) ||
 | ||
|       // #endif
 | ||
|       (Array.isArray(mediaType) && !mediaType?.includes?.(/\./.test(fileName) ? '.' + fileName?.split?.('.')?.pop?.()?.toLowerCase?.() : ''))
 | ||
|     ) {
 | ||
|       return { _state: 'UNSUPPORTED_FILETYPE', _msg: { zhCN: '不支持的文件类型:\n' + fileName, enUS: 'Unsupported file type:\n' + fileName } }
 | ||
|     }
 | ||
| 
 | ||
|     if (mediaType !== 'video' && mediaType !== 'image') {
 | ||
|       // 这一句应该在上面的 uniCloud.chooseAndUploadFile() 分支里,不过为了合用 fileSize 和 fileExt 的判断,就放在这里。
 | ||
|       return { _state: 'SUCCESS', fileUrl: filePicked?.url }
 | ||
|     }
 | ||
| 
 | ||
|     if (!filePath) {
 | ||
|       return {
 | ||
|         _state: 'BER_FAIL_CHOOSE_FILE',
 | ||
|         _msg: { zhCN: '文件上传失败。请稍后再试,或向客服报告。', enUS: 'File upload failed. Please try again, or report to customer service.' },
 | ||
|       }
 | ||
|     }
 | ||
| 
 | ||
|     this.showLoading({ title: { zhCN: '上传中...', enUS: 'Uploading...' } })
 | ||
|     const { fileID, requestId } = await uniCloud.uploadFile({
 | ||
|       filePath: filePath,
 | ||
|       cloudPath: (process.env.NODE_ENV !== 'production' ? 'dev_' : '') + cloudPath, // 关键是要具有文件格式后缀名,这样可以保持阿里云下载链接也用这个后缀名。
 | ||
|       //fileType: mediaType, // = image, video, audio. Looks like only necessary for for 支付宝小程序: https://uniapp.dcloud.net.cn/uniCloud/storage.html#uploadfile
 | ||
|       onUploadProgress: function (progressEvent) {
 | ||
|         var percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
 | ||
|       },
 | ||
|     })
 | ||
|     this.hideLoading()
 | ||
| 
 | ||
|     if (fileID) {
 | ||
|       return { _state: 'SUCCESS', fileUrl: fileID, requestId }
 | ||
|     } else {
 | ||
|       return {
 | ||
|         _state: 'BER_FAIL_UPLOAD_FILE',
 | ||
|         _msg: { zhCN: '文件上传失败。请稍后再试,或向客服报告。', enUS: 'File upload failed. Please try again, or report to customer service.' },
 | ||
|       }
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   async pickupFile ({
 | ||
|     baseType = globalThis.wo?.envar?.baseTypeDefault || this.BASE_TYPE_DEFAULT,
 | ||
|     fileDragged,
 | ||
|     mediaType = 'image', // could be image, video, array of supported extensions, anything else // 20240502 todo: rename to pickupFileType
 | ||
|     count = 1,
 | ||
|     sizeType = ['original', 'compressed'],
 | ||
|     sourceType = ['album', 'camera'],
 | ||
|     maxDuration,
 | ||
|     url,
 | ||
|     header = {},
 | ||
|     formData = {},
 | ||
|     name = 'file',
 | ||
|   } = {}) {
 | ||
|     if (/^UNICLOUD/.test(baseType)) {
 | ||
|       return await this.pickupFile2Cloud({ fileDragged, mediaType, count, sizeType, sourceType, maxDuration })
 | ||
|     } else if (baseType === 'SERVER') {
 | ||
|       return await this.pickupFile2Server({ fileDragged, mediaType, count, sizeType, sourceType, maxDuration, url, header, formData, name })
 | ||
|     } else {
 | ||
|       return { _state: 'CLEINT_FAIL_UNKNOWN_WOBASE_TYPE', baseType, _msg: { zhCN: '不支持的后台类型。', enUS: 'Unsupported base type.' } }
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   open_url ({ url, title, inWebview } = {}) {
 | ||
|     url = this.localizeText?.(url)
 | ||
|     if (!url) {
 | ||
|       return
 | ||
|     }
 | ||
|     if (wo.envar.inPc) {
 | ||
|       window.open(url, '_blank')
 | ||
|     } else if (inWebview) {
 | ||
|       wo.ss.webviewUrl = url
 | ||
|       wo.ss.webviewTitle = title
 | ||
|       uni.navigateTo({ url: 'show-webview' })
 | ||
|     } else {
 | ||
|       // #ifdef APP
 | ||
|       plus.runtime.openURL(url)
 | ||
|       // #endif
 | ||
|       // #ifdef WEB
 | ||
|       window.open(url, '_blank')
 | ||
|       // #endif
 | ||
|     }
 | ||
|   },
 | ||
|   open_url_in_browser ({ url }) {
 | ||
|     url = this.localizeText?.(url) || url
 | ||
|     // #ifdef APP
 | ||
|     plus.runtime.openURL(url)
 | ||
|     // #endif
 | ||
|     // #ifdef WEB
 | ||
|     window.open(url, '_blank')
 | ||
|     // #endif
 | ||
|   },
 | ||
|   open_url_in_webview ({ url, title }) {
 | ||
|     url = this.localizeText?.(url) || url
 | ||
|     if (wo.envar.inPc) {
 | ||
|       window.open(url, '_blank')
 | ||
|     } else {
 | ||
|       wo.ss.webviewUrl = url
 | ||
|       wo.ss.webviewTitle = title
 | ||
|       uni.navigateTo({ url: 'show-webview' })
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   parse_ua_to_browser () {
 | ||
|     // #ifdef WEB
 | ||
|     let ua = globalThis.window?.navigator?.userAgent?.toLowerCase?.()
 | ||
|     if (/msie/.test(ua) && !/opera/.test(ua)) {
 | ||
|       return 'msie'
 | ||
|     } else if (/opera/.test(ua)) {
 | ||
|       return 'opera'
 | ||
|     } else if (/version.*safari/.test(ua)) {
 | ||
|       return 'safari'
 | ||
|     } else if (/chrome/.test(ua)) {
 | ||
|       return 'chrome'
 | ||
|     } else if (/gecko/.test(ua) && !/webkit/.test(ua)) {
 | ||
|       return 'firefox'
 | ||
|     } else if (ua.match(/MicroMessenger/i) == 'micromessenger' && ua.match(/miniprogram/i) == 'miniprogram') {
 | ||
|       // 微信小程序
 | ||
|       return 'weixin_mp'
 | ||
|     } else if (/micromessenger/i.test(ua)) {
 | ||
|       // 微信公众号或微信浏览器
 | ||
|       return 'weixin'
 | ||
|     } else if (ua.match(/alipay/i) == 'alipay' && ua.match(/miniprogram/i) == 'miniprogram') {
 | ||
|       return 'alipay_mp'
 | ||
|     } else if (ua.match(/alipay/i) == 'alipay') {
 | ||
|       return 'alipay'
 | ||
|     } else {
 | ||
|       return 'unknown' // 只要在 H5, 即使不认识也要返回一个名称。
 | ||
|     }
 | ||
|     // #endif
 | ||
|     return '' // 如果不在 H5。
 | ||
|   },
 | ||
| 
 | ||
|   /*
 | ||
|    * 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 ({ type = 'success', image, title, duration = 2000, wo = globalThis.wo, sysToast = false, ...rest } = {}) {
 | ||
|     title = this.localizeText(title)
 | ||
|     if (!title) {
 | ||
|       return
 | ||
|     }
 | ||
|     const mypopup =
 | ||
|       globalThis.getCurrentPages?.()?.pop()?.mypopup ||
 | ||
|       globalThis.getCurrentPages?.()?.pop()?.$refs?.mypopup ||
 | ||
|       globalThis.getApp().globalData?.mypopup ||
 | ||
|       wo?.mypopup
 | ||
|     const mytoast =
 | ||
|       globalThis.getCurrentPages?.()?.pop()?.mytoast ||
 | ||
|       globalThis.getCurrentPages?.()?.pop()?.$refs?.mytoast ||
 | ||
|       globalThis.getApp().globalData?.mytoast ||
 | ||
|       wo?.mytoast
 | ||
|     if (!sysToast && mypopup) {
 | ||
|       wo.ss.popMessage = title
 | ||
|       wo.ss.popType = type // success/error/warning/info
 | ||
|       wo.ss.popDuration = duration
 | ||
|       mypopup.open()
 | ||
|     } else if (!sysToast && mytoast) {
 | ||
|       // 来自 <ucToast> 或 <u-toast> 或 <u-top-tips> // rename to popToast?
 | ||
|       mytoast?.show?.({ type, title, duration, ...rest })
 | ||
|     } else {
 | ||
|       // #ifdef APP
 | ||
|       uni.showToast({ icon: ['error', 'success'].includes(type) ? type : 'none', title, duration, ...rest })
 | ||
|       // plus.nativeUI.toast( title, { align: center/left/right, verticalAlign: bottom/center/top, duration:long/short, icon, iconWidth, iconHeight, style: block/inline, type:text/richtext })
 | ||
|       // #endif
 | ||
|       // #ifndef APP
 | ||
|       uni.showToast({ icon: ['error', 'success'].includes(type) ? type : 'none', image, title, duration, ...rest })
 | ||
|       // #endif
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   showModal (option = {}) {
 | ||
|     option = { ...option } // 避免在 option 里直接增删。
 | ||
|     option.title = this.localizeText(option.title)
 | ||
|     option.content =
 | ||
|       (uni.getSystemInfoSync().uniPlatform === 'app' ? '\n' : '') + this.localizeText(option.content)?.substring?.(0, option.contentLength || 300)
 | ||
|     if (option.content) option.content += '\n\n'
 | ||
|     option.cancelText = this.localizeText(option.cancelText || { zhCN: '取消', enUS: 'Cancel', deDE: 'Abbrechen', jaJP: 'キャンセル' })
 | ||
|     option.confirmText = this.localizeText(
 | ||
|       option.confirmText ||
 | ||
|         (option.showCancel === true || option.showCancel === undefined
 | ||
|           ? { zhCN: '确认', enUS: 'Confirm', deDE: 'Bestätigen', jaJP: '確認' }
 | ||
|           : { zhCN: '好的', enUS: 'OK', deDE: 'Ja', jaJP: 'OK' })
 | ||
|     )
 | ||
|     // #ifdef APP
 | ||
|     if (option.content) option.content = '\n' + option.content
 | ||
|     else option.content = ' ' // 20240229 从前在手机app里,content必须存在。现在似乎没关<-系了,但不确定。
 | ||
|     // #endif
 | ||
|     uni.showModal(option) // uni.showModal 的 showCancel 默认为 true
 | ||
|   },
 | ||
| 
 | ||
|   showLoading ({ title = { zhCN: '加载中...', enUS: 'Loading...', deDE: 'Laden...', jaJP: '読み込み中...' }, mask = false } = {}) {
 | ||
|     title = this.localizeText(title)
 | ||
|     // #ifndef APP
 | ||
|     uni.showLoading({ title, mask }) // 原函数的 mask 默认为 false
 | ||
|     // #endif
 | ||
|     // #ifdef APP
 | ||
|     // 在安卓应用里,uni.showLoading() 重复调用,导致不断闪烁跳动。
 | ||
|     // plus.nativeUI.showWaiting() 调用多了则导致死机。
 | ||
|     // 还好,showWaiting() 返回 waiting 对象,可以 waiting.setTitle()
 | ||
|     return plus.nativeUI.showWaiting(title, { modal: mask }) // 原函数的 mask 默认为 true
 | ||
|     // #endif
 | ||
|   },
 | ||
|   hideLoading () {
 | ||
|     // #ifndef APP
 | ||
|     uni.hideLoading()
 | ||
|     // #endif
 | ||
|     // #ifdef APP
 | ||
|     plus.nativeUI.closeWaiting()
 | ||
|     // #endif
 | ||
|   },
 | ||
| 
 | ||
|   // precision 要有默认值,以防无法连接后台时,这个方法会导致 part-header.vue 出错。
 | ||
|   formatMoney (amount, { precision = 2 } = {}) {
 | ||
|     // parseInt(NaN/undefined/false/null/'') 都返回 NaN,而 Number(false/null/'')===0,因此用 parseInt 来过滤无效输入。
 | ||
|     // 或者可以 if (!['number', 'string'].includes(typeof amount) && [NaN, undefined, false, null, ''].includes(amount))
 | ||
|     if (Number.isNaN(parseInt(amount))) {
 | ||
|       return ''
 | ||
|     }
 | ||
|     // Number(amount).toFixed(precision) // toFixed 虽然方便,但是会自动四舍五入。
 | ||
|     return `${parseInt(Number(amount) * Math.pow(10, precision)) / Math.pow(10, precision)}`
 | ||
|   },
 | ||
| 
 | ||
|   formatPercent (value, precision = 2) {
 | ||
|     return Number(value * 100 || 0).toFixed(precision)
 | ||
|   },
 | ||
| 
 | ||
|   formatDate (date, format) {
 | ||
|     if (typeof date === 'string' && date) {
 | ||
|       // new Date('') ==> Invalid Date
 | ||
|       if (/^\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
 | ||
|       } else if (/^\d{13}$/.test(date)) {
 | ||
|         // new Date('1702179364450') ==> Invalid Date,所以要先转成纯数字
 | ||
|         date = parseInt(date)
 | ||
|       } else if (/^\d{10}$/.test(date)) {
 | ||
|         date = parseInt(date) * 1000
 | ||
|       }
 | ||
|       date = new Date(date)
 | ||
|     } else if (typeof date === 'number') {
 | ||
|       date = new Date(date)
 | ||
|     }
 | ||
| 
 | ||
|     if (!(date instanceof Date) || !date?.toJSON?.()) {
 | ||
|       return ''
 | ||
|     }
 | ||
| 
 | ||
|     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标签
 | ||
|       // <iframe id="iframe" sandbox="allow-same-origin" style="display:none;"></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, { type = 'navigateTo', wo = globalThis.wo } = {}) {
 | ||
|     if (pageName) {
 | ||
|       if (wo?.pagesJson?.tabBar?.list?.find((item) => item?.pagePath?.substr(6) === pageName)) {
 | ||
|         // 注意,即使在 PC 上 topWindow 代替了 tabBar 时,从标签页转化而来的菜单页,也是用 switchTab 跳转。
 | ||
|         uni.switchTab({ url: pageName })
 | ||
|       } else if (type === 'navigateTo') {
 | ||
|         uni.navigateTo({ url: pageName })
 | ||
|       } else {
 | ||
|         uni.redirectTo({ url: pageName })
 | ||
|       }
 | ||
|     } else {
 | ||
|       uni.navigateBack()
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   next_focus (currentFocus, focusList) {
 | ||
|     focusList = focusList || globalThis.getCurrentPages?.()?.pop()?.focusList
 | ||
|     if (focusList) {
 | ||
|       for (let n in focusList) {
 | ||
|         // 不论对数组或对象都有效,n 是数组的index 或对象的key
 | ||
|         focusList[n] = false
 | ||
|       }
 | ||
|       setTimeout(() => {
 | ||
|         focusList[(currentFocus + 1) % Object.keys(focusList).length] = true
 | ||
|       }, 200) // 如果没有 setTimeout 至少 200ms, 焦点短暂跳到下一个后,又会消失
 | ||
|     }
 | ||
|   },
 | ||
| 
 | ||
|   copy_to_clipboard (text, { promptLength = 50, hidePrompt = false, sysToast = false } = {}) {
 | ||
|     text = this.localizeText?.(text) || text
 | ||
|     const self = this
 | ||
|     uni.setClipboardData({
 | ||
|       data: text,
 | ||
|       success: () => {
 | ||
|         uni.hideToast()
 | ||
|         if (!hidePrompt) {
 | ||
|           if (text.length > promptLength) {
 | ||
|             text = String(text).substring(0, promptLength) + ' ...'
 | ||
|           }
 | ||
|           self.showToast?.({
 | ||
|             type: 'success',
 | ||
|             title: `${this.localizeText?.({ zhCN: '已拷贝\n', enUS: 'Copied\n' }) || ''}${text}`,
 | ||
|             sysToast,
 | ||
|           })
 | ||
|         }
 | ||
|       },
 | ||
|     })
 | ||
|   },
 | ||
| }
 |