wo-user-toolkit-uniapp/unitool.js
2025-01-17 16:14:09 +08:00

927 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// uniapp client tools
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 BASE_TYPE_DEFAULT = 'SERVER' // one of { SERVER: 服务器, UNICLOUD_FUNC: 云函数, UNICLOUD_OBJECT: 云对象 }
const my = {
langNow () {
// 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 = {
// 用直观的色彩,代替语义化的类型:
// 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 || ['txt', 'text']).includes(ext)
},
is_image_file (fileName = '') {
const ext = /\./.test(fileName) ? fileName.split('.').pop().toLowerCase() : ''
return (wo.envar.imageExtensionList || ['jpg', 'jpeg', 'png', 'gif', 'webp', 'image']).includes(ext)
},
is_video_file (fileName = '') {
const ext = /\./.test(fileName) ? fileName.split('.').pop().toLowerCase() : ''
return (wo.envar.videoExtensionList || ['avi', 'mp4', 'mov', 'wmv', 'video']).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] ||
i18nText?.[my.langNow()] ||
(precise ? '' : i18nText?.earTH || i18nText?.defLAN || i18nText?.gloBAL || i18nText?.enUS || Object.values(i18nText)[0] || '')
)
} else {
return ''
}
},
localeText () {
// 专供绑定到 computed { lote: wo?.localeText } 使用,这时 this 就是当前页面。
return this.i18nText?.[my.langNow()] || {}
},
setBarTitles ({ windowTitle, pageTitle, wo = globalThis.wo } = {}) {
const langNow = my.langNow()
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 || wo?.envar?.callnames?.[langNow] || wo?.pagesJson?.appInfo?.i18nText?.[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 里,如果使用了 midButtontabBarItem的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 || {}) {
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
}
// 纯数字和字母的cid
if (/^[\da-zA-Z]+$/.test(route) && envar.ipfsLens) {
return `${envar.ipfsLens.replace(/\/$/, '')}/${route.replace(/^\//, '')}`
}
//// base url / 后台服务器url 需要组装。包括了 route === '_filestore/xxx' 的情况
route = route.replace(/^\//, '')
// 已有现成后端服务域名
if (envar.servUrl) {
return `${envar.servUrl.replace(/\/$/, '')}/${route}`
} else {
// 需要组装后端服务域名
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}`
}
},
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 || 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)
}
// 注意1resultServer 和 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' : '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 {
// #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 || 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 userAgent = globalThis.window?.navigator?.userAgent?.toLowerCase?.()
return /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' // 只要在 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: '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: 'none', image, title, duration, ...rest })
// #endif
}
},
showModal (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', jaJP: 'キャンセル' })
option.confirmText = this.localizeText(
option.confirmText ||
(option.showCanel === true || option.showCancel === undefined
? { zhCN: '确认', enUS: 'Confirm', jaJP: '確認' }
: { zhCN: '好的', enUS: 'OK', 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...' }, 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,
})
}
},
})
},
}