index.ts 20 KB

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