index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. /* eslint-disable */
  2. import { v4 } from 'uuid'
  3. import { SystemInfo, ShareMessage, HttpRequestConfig, Download } from './types'
  4. import { urlScheme } from './constants'
  5. declare global {
  6. interface Window {
  7. plus: any;
  8. }
  9. }
  10. interface AndroidErrorCallback {
  11. code: number;
  12. message: string;
  13. }
  14. export default new (class {
  15. private readonly plusready = new Promise<void>((resolve) => {
  16. if (this.hasPlus()) {
  17. resolve()
  18. } else {
  19. document.addEventListener('plusready', () => resolve())
  20. }
  21. })
  22. /**
  23. * 网络请求对象
  24. */
  25. private xhr = new XMLHttpRequest()
  26. /**
  27. * 当前下载进度任务
  28. */
  29. private progressTask = new Map()
  30. /**
  31. * 系统信息
  32. */
  33. private systemInfo: SystemInfo = {
  34. os: 'Web', // 客户端操作系统
  35. version: '1.0.0', // 客户端版本号
  36. versionCode: '100000', // 客户端版本代码
  37. statusBarHeight: 0, // 状态栏高度
  38. }
  39. constructor() {
  40. this.onPlusReady((plus) => {
  41. this.xhr = new plus.net.XMLHttpRequest()
  42. this.systemInfo.os = plus.os.name
  43. this.systemInfo.statusBarHeight = plus.navigator.getStatusbarHeight()
  44. plus.runtime.getProperty(plus.runtime.appid, (info: any) => {
  45. this.systemInfo.version = info.version
  46. this.systemInfo.versionCode = info.versionCode
  47. })
  48. })
  49. // 监听返回按钮事件
  50. this.onPlusReady((plus) => {
  51. let firstBack = true
  52. plus.key.addEventListener('backbutton', () => {
  53. const webviews = plus.webview.all() // 所有Webview窗口
  54. if (webviews.length > 1) {
  55. plus.webview.close(webviews[webviews.length - 1])
  56. } else {
  57. const webview = plus.webview.currentWebview()
  58. webview.canBack((e: any) => {
  59. // 判断能否继续返回
  60. if (e.canBack) {
  61. webview.back()
  62. } else {
  63. // 1秒内连续两次按返回键退出应用
  64. if (firstBack) {
  65. firstBack = false
  66. plus.nativeUI.toast('再按一次退出应用')
  67. setTimeout(() => {
  68. firstBack = true
  69. }, 1000)
  70. } else {
  71. plus.runtime.quit()
  72. }
  73. }
  74. })
  75. }
  76. })
  77. })
  78. }
  79. hasPlus() {
  80. return !!window.plus
  81. }
  82. onPlusReady(callback: (plus: Window['plus']) => void) {
  83. this.plusready.then(() => {
  84. callback(window.plus)
  85. })
  86. }
  87. /**
  88. * 退出应用程序
  89. */
  90. quit() {
  91. this.onPlusReady((plus) => {
  92. plus.runtime.quit()
  93. })
  94. }
  95. /**
  96. * 获取系统信息
  97. * @param prop
  98. * @returns
  99. */
  100. getSystemInfo<K extends keyof SystemInfo>(prop: K) {
  101. return this.systemInfo[prop]
  102. }
  103. /**
  104. * 获取状态栏高度
  105. * @param callback
  106. */
  107. getStatusBarHeight(callback: (statusbarHeight: number) => void) {
  108. this.onPlusReady((plus) => {
  109. const height = plus.navigator.getStatusbarHeight()
  110. callback(height)
  111. })
  112. }
  113. /**
  114. * 设置状态栏文字颜色
  115. * @param color dark - 暗色,light - 亮色
  116. */
  117. setStatusBarStyle(color: 'dark' | 'light') {
  118. this.onPlusReady((plus) => {
  119. plus.navigator.setStatusBarStyle(color)
  120. })
  121. }
  122. /**
  123. * 隐藏状态栏
  124. */
  125. hideStatusBar() {
  126. this.onPlusReady((plus) => {
  127. plus.navigator.setFullscreen(true)
  128. })
  129. }
  130. /**
  131. * 显示状态栏
  132. */
  133. showStatusBar() {
  134. this.onPlusReady((plus) => {
  135. plus.navigator.setFullscreen(false)
  136. })
  137. }
  138. /**
  139. * 设置应用全屏
  140. */
  141. setFullSreen() {
  142. this.onPlusReady((plus) => {
  143. this.hideStatusBar()
  144. plus.navigator.hideSystemNavigation()
  145. })
  146. }
  147. /**
  148. * 应用退出全屏
  149. */
  150. exitFullSreen() {
  151. this.onPlusReady((plus) => {
  152. this.showStatusBar()
  153. plus.navigator.showSystemNavigation()
  154. })
  155. }
  156. /**
  157. * http 跨域请求(待完善)
  158. * @param config
  159. * @returns
  160. */
  161. httpRequest(config: HttpRequestConfig) {
  162. return new Promise<any>((resolve, reject) => {
  163. this.xhr.responseType = config.responseType ?? 'json'
  164. if (config.header) {
  165. for (const key in config.header) {
  166. this.xhr.setRequestHeader(key, config.header[key])
  167. }
  168. }
  169. this.xhr.onreadystatechange = () => {
  170. if (this.xhr.readyState === 4) {
  171. if (this.xhr.status == 200) {
  172. resolve({
  173. code: 200,
  174. data: this.xhr.response
  175. })
  176. } else {
  177. reject({
  178. code: this.xhr.status,
  179. message: this.xhr.statusText
  180. })
  181. }
  182. }
  183. }
  184. this.xhr.open(config.method ?? 'GET', config.url)
  185. this.xhr.send()
  186. })
  187. }
  188. /**
  189. * 删除文件
  190. * @param url
  191. */
  192. deleteFile(url: string) {
  193. this.onPlusReady((plus) => {
  194. plus.io.resolveLocalFileSystemURL(url, (entry: any) => {
  195. entry.remove()
  196. })
  197. })
  198. }
  199. /**
  200. * 监听下载进度
  201. * @param callback
  202. * @returns
  203. */
  204. onDownloadProgress(callback: (progress: number) => void) {
  205. const uuid = v4()
  206. this.progressTask.set(uuid, callback)
  207. /** 注意离开页面时销毁监听事件,防止事件重复触发 */
  208. return {
  209. uuid,
  210. cancel: () => this.progressTask.delete(uuid)
  211. }
  212. }
  213. /**
  214. * 文件下载
  215. * https://www.html5plus.org/doc/zh_cn/downloader.html#plus.downloader.createDownload
  216. * @param url
  217. */
  218. createDownload(url: string) {
  219. return new Promise<Download>((resolve, reject) => {
  220. this.onPlusReady((plus) => {
  221. // plus.downloader.enumerate((downloads: any) => {
  222. // if (downloads.length) {
  223. // plus.nativeUI.toast('正在下载')
  224. // } else {
  225. // }
  226. // })
  227. const task = plus.downloader.createDownload(url, {
  228. filename: '_downloads/', // 非系统 Download 目录
  229. retry: 1,
  230. }, (download: Download, status: number) => {
  231. if (status === 200) {
  232. resolve(download)
  233. } else {
  234. reject('下载失败,请稍后再试')
  235. }
  236. this.progressTask.clear()
  237. })
  238. // 监听下载状态
  239. task.addEventListener('statechanged', (e: Download) => {
  240. //console.log(e.state, e.downloadedSize / e.totalSize * 100)
  241. if (e.state === 3) {
  242. const progress = e.downloadedSize / e.totalSize * 100
  243. for (const fn of this.progressTask.values()) {
  244. fn(progress) // 推送下载进度
  245. }
  246. }
  247. })
  248. // 开始下载
  249. task.start()
  250. })
  251. })
  252. }
  253. /**
  254. * App更新安装
  255. * @param file
  256. */
  257. installApp(file: string) {
  258. this.onPlusReady((plus) => {
  259. plus.nativeUI.showWaiting('正在安装...')
  260. plus.runtime.install(file, {
  261. // true表示强制安装,不进行版本号的校验;false则需要版本号校验,如果将要安装应用的版本号不高于现有应用的版本号则终止安装,并返回安装失败。 仅安装wgt和wgtu时生效,默认值 false
  262. force: false
  263. }, () => {
  264. console.log('安装成功!')
  265. this.deleteFile(file)
  266. plus.nativeUI.closeWaiting()
  267. plus.runtime.restart()
  268. }, (e: any) => {
  269. plus.nativeUI.closeWaiting()
  270. plus.nativeUI.alert('安装失败:' + e.message)
  271. })
  272. })
  273. }
  274. /**
  275. * 保存图片到相册
  276. * @param base64Data
  277. */
  278. saveImage(base64Data: string, fileName?: string) {
  279. this.onPlusReady((plus) => {
  280. const bitmap = new plus.nativeObj.Bitmap()
  281. const filename = fileName ?? new Date().getTime()
  282. bitmap.loadBase64Data(base64Data)
  283. bitmap.save(`_doc/${filename}.jpg`, { overwrite: true, quality: 100, }, (e: Event) => {
  284. //保存到系统相册
  285. plus.gallery.save(
  286. e.target,
  287. () => {
  288. //销毁Bitmap图片
  289. bitmap.clear()
  290. plus.nativeUI.toast('已保存到相册中')
  291. },
  292. () => {
  293. //销毁Bitmap图片
  294. bitmap.clear()
  295. plus.nativeUI.toast('保存失败')
  296. }
  297. )
  298. }, (err: { message: string }) => {
  299. plus.nativeUI.toast(err.message)
  300. })
  301. })
  302. }
  303. /**
  304. * https://www.html5plus.org/doc/zh_cn/runtime.html#plus.runtime.openURL
  305. * @param url
  306. */
  307. openURL(url: string) {
  308. if (this.hasPlus()) {
  309. this.onPlusReady((plus) => {
  310. plus.runtime.openURL(url)
  311. })
  312. } else {
  313. window.open(url)
  314. }
  315. }
  316. /**
  317. * https://www.html5plus.org/doc/zh_cn/webview.html#plus.webview.create
  318. * @param options
  319. */
  320. openWebview(options: { url: string; id?: string; titleText?: string; titleColor?: string; backgroundColor?: string; onClose?: () => void; }) {
  321. if (this.hasPlus()) {
  322. const styles = {
  323. titleNView: {
  324. backgroundColor: options.backgroundColor,
  325. titleText: options.titleText,
  326. titleColor: options.titleColor,
  327. autoBackButton: true,
  328. }
  329. }
  330. this.onPlusReady((plus) => {
  331. const wv = plus.webview.create(options.url, options.id ?? v4(), styles)
  332. wv.show()
  333. wv.addEventListener('close', () => options.onClose && options.onClose(), false)
  334. })
  335. } else {
  336. this.openURL(options.url)
  337. }
  338. }
  339. /**
  340. * 将本地URL路径转换成平台绝对路径
  341. * https://www.html5plus.org/doc/zh_cn/io.html#plus.io.convertLocalFileSystemURL
  342. * @param url
  343. */
  344. convertLocalFileSystemURL(url: string) {
  345. return new Promise<string>((resolve) => {
  346. if (this.hasPlus()) {
  347. this.onPlusReady((plus) => {
  348. const localURL = plus.io.convertLocalFileSystemURL('_www/' + url)
  349. resolve(localURL)
  350. })
  351. } else {
  352. const absoluteURL = new URL(url, window.location.href).href
  353. resolve(absoluteURL)
  354. }
  355. })
  356. }
  357. /**
  358. * 读取本地文件内容
  359. * https://www.html5plus.org/doc/zh_cn/io.html#plus.io.resolveLocalFileSystemURL
  360. * @param filePath
  361. * @returns
  362. */
  363. getLocalFileContent(filePath: string) {
  364. return new Promise<any>((resolve, reject) => {
  365. this.onPlusReady((plus) => {
  366. plus.io.resolveLocalFileSystemURL('_www/' + filePath, (entry: any) => {
  367. entry.file((file: any) => {
  368. const fileReader = new plus.io.FileReader()
  369. fileReader.readAsText(file, 'utf-8')
  370. fileReader.onloadend = (evt: any) => {
  371. resolve(evt.target.result)
  372. }
  373. })
  374. }, (e: any) => {
  375. reject(e.message)
  376. })
  377. })
  378. })
  379. }
  380. /**
  381. * 系统分享
  382. * https://www.html5plus.org/doc/zh_cn/share.html#plus.share.sendWithSystem
  383. * @param message
  384. * @returns
  385. */
  386. systemShare(message: Partial<ShareMessage>) {
  387. return new Promise<void>((resolve, reject) => {
  388. this.onPlusReady((plus) => {
  389. const logo = plus.io.convertLocalFileSystemURL('./logo.gif')
  390. const shareMessage: Partial<ShareMessage> = {
  391. type: 'web',
  392. thumbs: ['file://' + logo],
  393. ...message,
  394. }
  395. plus.share.sendWithSystem(shareMessage, () => {
  396. resolve()
  397. }, (e: { code: number; message: string; }) => {
  398. reject(e.message)
  399. })
  400. })
  401. })
  402. }
  403. /**
  404. * 内容分享
  405. * https://www.html5plus.org/doc/zh_cn/share.html#plus.share.getServices
  406. */
  407. share() {
  408. this.onPlusReady((plus) => {
  409. // 成功回调
  410. const success = (services: any) => {
  411. console.log('分享列表', services)
  412. services.forEach((e: any) => {
  413. if (e.id === 'weixin') {
  414. e.send({
  415. type: 'web',
  416. title: '标题',
  417. content: '内容',
  418. href: 'https://',
  419. }, () => {
  420. console.log('分享成功')
  421. }, (e: Error) => {
  422. console.log('分享失败', e.message)
  423. })
  424. }
  425. })
  426. }
  427. // 失败回调
  428. const error = (e: Error) => {
  429. console.log('获取分享列表失败', e.message)
  430. }
  431. plus.share.getServices(success, error)
  432. })
  433. }
  434. /**
  435. * 打开第三方APP
  436. * https://www.html5plus.org/doc/zh_cn/runtime.html#plus.runtime.launchApplication
  437. * @param app
  438. */
  439. launchApplication<K extends keyof typeof urlScheme>(app: K) {
  440. this.onPlusReady((plus) => {
  441. const os = this.getSystemInfo('os')
  442. const params = Object.create(null)
  443. if (os === 'Android') {
  444. params.pname = urlScheme[app].pname
  445. }
  446. if (os === 'iOS') {
  447. params.action = urlScheme[app].scheme
  448. }
  449. plus.runtime.launchApplication(params, (e: Error) => {
  450. console.log('失败', e.message)
  451. })
  452. })
  453. }
  454. /**
  455. * 请求摄像头权限
  456. */
  457. requestPermissionCamera(options: Partial<{ onSuccess: () => void; onError: (message: string) => void; }> = {}) {
  458. const { onSuccess, onError } = options
  459. if (this.hasPlus()) {
  460. this.onPlusReady((plus) => {
  461. plus.android.requestPermissions(['android.permission.CAMERA'], (e: { granted: string[]; deniedPresent: string[]; deniedAlways: string[]; }) => {
  462. if (e.deniedAlways.length > 0) {
  463. onError && onError('访问摄像头被拒绝')
  464. }
  465. if (e.deniedPresent.length > 0) {
  466. onError && onError('请打开摄像头权限')
  467. }
  468. if (e.granted.length > 0) {
  469. onSuccess && onSuccess()
  470. }
  471. }, (e: AndroidErrorCallback) => {
  472. onError && onError(e.message)
  473. })
  474. })
  475. } else {
  476. onSuccess && onSuccess()
  477. }
  478. }
  479. /**
  480. * 请求麦克风权限
  481. */
  482. requestPermissionRecordAudio(options: Partial<{ onSuccess: () => void; onError: (message: string) => void; }> = {}) {
  483. const { onSuccess, onError } = options
  484. if (this.hasPlus()) {
  485. this.onPlusReady((plus) => {
  486. plus.android.requestPermissions(['android.permission.RECORD_AUDIO'], (e: { granted: string[]; deniedPresent: string[]; deniedAlways: string[]; }) => {
  487. if (e.deniedAlways.length > 0) {
  488. onError && onError('访问麦克风被拒绝')
  489. }
  490. if (e.deniedPresent.length > 0) {
  491. onError && onError('请打开麦克风权限')
  492. }
  493. if (e.granted.length > 0) {
  494. onSuccess && onSuccess()
  495. }
  496. }, (e: AndroidErrorCallback) => {
  497. onError && onError(e.message)
  498. })
  499. })
  500. } else {
  501. onSuccess && onSuccess()
  502. }
  503. }
  504. })