index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. /* eslint-disable */
  2. import { v4 } from 'uuid'
  3. import { SystemInfo, ShareMessage, HttpRequestConfig } 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 downloadTask = 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. onDownload(callback: (filename: string, progress: number) => void) {
  205. const uuid = v4()
  206. this.downloadTask.set(uuid, callback)
  207. /** 注意离开页面时销毁监听事件,防止事件重复触发 */
  208. return {
  209. uuid,
  210. cancel: () => this.downloadTask.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. this.onPlusReady((plus) => {
  220. // plus.downloader.enumerate((downloads: any) => {
  221. // if (downloads.length) {
  222. // plus.nativeUI.toast('正在下载')
  223. // } else {
  224. // }
  225. // })
  226. const task = plus.downloader.createDownload(url, {
  227. filename: '_downloads/', // 非系统 Download 目录
  228. retry: 1,
  229. }, (d: any, status: number) => {
  230. if (status !== 200) {
  231. plus.nativeUI.toast('下载失败,请稍后再试')
  232. }
  233. })
  234. // 监听下载状态
  235. task.addEventListener('statechanged', (task: any) => {
  236. console.log(task.state, task.downloadedSize / task.totalSize * 100)
  237. switch (task.state) {
  238. case 3:
  239. const progress = task.downloadedSize / task.totalSize * 100
  240. for (const fn of this.downloadTask.values()) {
  241. fn(task.filename, progress) // 推送下载进度
  242. }
  243. break
  244. case 4:
  245. console.log('下载完成', task.filename)
  246. this.downloadTask.clear()
  247. break
  248. }
  249. })
  250. // 开始下载
  251. task.start()
  252. })
  253. }
  254. /**
  255. * App更新安装
  256. * @param file
  257. */
  258. installApp(file: string) {
  259. this.onPlusReady((plus) => {
  260. plus.nativeUI.showWaiting('正在安装...')
  261. plus.runtime.install(file, {
  262. // true表示强制安装,不进行版本号的校验;false则需要版本号校验,如果将要安装应用的版本号不高于现有应用的版本号则终止安装,并返回安装失败。 仅安装wgt和wgtu时生效,默认值 false
  263. force: false
  264. }, () => {
  265. console.log('安装成功!')
  266. this.deleteFile(file)
  267. plus.nativeUI.closeWaiting()
  268. plus.runtime.restart()
  269. }, (e: any) => {
  270. plus.nativeUI.closeWaiting()
  271. plus.nativeUI.alert('安装失败:' + e.message)
  272. })
  273. })
  274. }
  275. /**
  276. * 保存图片到相册
  277. * @param base64Data
  278. */
  279. saveImage(base64Data: string, fileName?: string) {
  280. this.onPlusReady((plus) => {
  281. const bitmap = new plus.nativeObj.Bitmap()
  282. const filename = fileName ?? new Date().getTime()
  283. bitmap.loadBase64Data(base64Data)
  284. bitmap.save(`_doc/${filename}.jpg`, { overwrite: true, quality: 100, }, (e: Event) => {
  285. //保存到系统相册
  286. plus.gallery.save(
  287. e.target,
  288. () => {
  289. //销毁Bitmap图片
  290. bitmap.clear()
  291. plus.nativeUI.toast('已保存到相册中')
  292. },
  293. () => {
  294. //销毁Bitmap图片
  295. bitmap.clear()
  296. plus.nativeUI.toast('保存失败')
  297. }
  298. )
  299. }, (err: { message: string }) => {
  300. plus.nativeUI.toast(err.message)
  301. })
  302. })
  303. }
  304. /**
  305. * https://www.html5plus.org/doc/zh_cn/runtime.html#plus.runtime.openURL
  306. * @param url
  307. */
  308. openURL(url: string) {
  309. if (this.hasPlus()) {
  310. this.onPlusReady((plus) => {
  311. plus.runtime.openURL(url)
  312. })
  313. } else {
  314. window.open(url)
  315. }
  316. }
  317. /**
  318. * https://www.html5plus.org/doc/zh_cn/webview.html#plus.webview.create
  319. * @param options
  320. */
  321. openWebview(options: { url: string; id?: string; titleText?: string; titleColor?: string; backgroundColor?: string; onClose?: () => void; }) {
  322. if (this.hasPlus()) {
  323. const styles = {
  324. titleNView: {
  325. backgroundColor: options.backgroundColor,
  326. titleText: options.titleText,
  327. titleColor: options.titleColor,
  328. autoBackButton: true,
  329. }
  330. }
  331. this.onPlusReady((plus) => {
  332. const wv = plus.webview.create(options.url, options.id ?? v4(), styles)
  333. wv.show()
  334. wv.addEventListener('close', () => options.onClose && options.onClose(), false)
  335. })
  336. } else {
  337. this.openURL(options.url)
  338. }
  339. }
  340. /**
  341. * 将本地URL路径转换成平台绝对路径
  342. * https://www.html5plus.org/doc/zh_cn/io.html#plus.io.convertLocalFileSystemURL
  343. * @param url
  344. */
  345. convertLocalFileSystemURL(url: string) {
  346. return new Promise<string>((resolve) => {
  347. if (this.hasPlus()) {
  348. this.onPlusReady((plus) => {
  349. const localURL = plus.io.convertLocalFileSystemURL('_www/' + url)
  350. resolve(localURL)
  351. })
  352. } else {
  353. const absoluteURL = new URL(url, window.location.href).href
  354. resolve(absoluteURL)
  355. }
  356. })
  357. }
  358. /**
  359. * 读取本地文件内容
  360. * https://www.html5plus.org/doc/zh_cn/io.html#plus.io.resolveLocalFileSystemURL
  361. * @param filePath
  362. * @returns
  363. */
  364. getLocalFileContent(filePath: string) {
  365. return new Promise<any>((resolve, reject) => {
  366. this.onPlusReady((plus) => {
  367. plus.io.resolveLocalFileSystemURL('_www/' + filePath, (entry: any) => {
  368. entry.file((file: any) => {
  369. const fileReader = new plus.io.FileReader()
  370. fileReader.readAsText(file, 'utf-8')
  371. fileReader.onloadend = (evt: any) => {
  372. resolve(evt.target.result)
  373. }
  374. })
  375. }, (e: any) => {
  376. reject(e.message)
  377. })
  378. })
  379. })
  380. }
  381. /**
  382. * 系统分享
  383. * https://www.html5plus.org/doc/zh_cn/share.html#plus.share.sendWithSystem
  384. * @param message
  385. * @returns
  386. */
  387. systemShare(message: Partial<ShareMessage>) {
  388. return new Promise<void>((resolve, reject) => {
  389. this.onPlusReady((plus) => {
  390. const logo = plus.io.convertLocalFileSystemURL('./logo.gif')
  391. const shareMessage: Partial<ShareMessage> = {
  392. type: 'web',
  393. thumbs: ['file://' + logo],
  394. ...message,
  395. }
  396. plus.share.sendWithSystem(shareMessage, () => {
  397. resolve()
  398. }, (e: { code: number; message: string; }) => {
  399. reject(e.message)
  400. })
  401. })
  402. })
  403. }
  404. /**
  405. * 内容分享
  406. * https://www.html5plus.org/doc/zh_cn/share.html#plus.share.getServices
  407. */
  408. share() {
  409. this.onPlusReady((plus) => {
  410. // 成功回调
  411. const success = (services: any) => {
  412. console.log('分享列表', services)
  413. services.forEach((e: any) => {
  414. if (e.id === 'weixin') {
  415. e.send({
  416. type: 'web',
  417. title: '标题',
  418. content: '内容',
  419. href: 'https://',
  420. }, () => {
  421. console.log('分享成功')
  422. }, (e: Error) => {
  423. console.log('分享失败', e.message)
  424. })
  425. }
  426. })
  427. }
  428. // 失败回调
  429. const error = (e: Error) => {
  430. console.log('获取分享列表失败', e.message)
  431. }
  432. plus.share.getServices(success, error)
  433. })
  434. }
  435. /**
  436. * 打开第三方APP
  437. * https://www.html5plus.org/doc/zh_cn/runtime.html#plus.runtime.launchApplication
  438. * @param app
  439. */
  440. launchApplication<K extends keyof typeof urlScheme>(app: K) {
  441. this.onPlusReady((plus) => {
  442. const os = this.getSystemInfo('os')
  443. const params = Object.create(null)
  444. if (os === 'Android') {
  445. params.pname = urlScheme[app].pname
  446. }
  447. if (os === 'iOS') {
  448. params.action = urlScheme[app].scheme
  449. }
  450. plus.runtime.launchApplication(params, (e: Error) => {
  451. console.log('失败', e.message)
  452. })
  453. })
  454. }
  455. /**
  456. * 请求摄像头权限
  457. */
  458. requestPermissionCamera(options: Partial<{ onSuccess: () => void; onError: (message: string) => void; }> = {}) {
  459. const { onSuccess, onError } = options
  460. if (this.hasPlus()) {
  461. this.onPlusReady((plus) => {
  462. plus.android.requestPermissions(['android.permission.CAMERA'], (e: { granted: string[]; deniedPresent: string[]; deniedAlways: string[]; }) => {
  463. if (e.deniedAlways.length > 0) {
  464. onError && onError('访问摄像头被拒绝')
  465. }
  466. if (e.deniedPresent.length > 0) {
  467. onError && onError('请打开摄像头权限')
  468. }
  469. if (e.granted.length > 0) {
  470. onSuccess && onSuccess()
  471. }
  472. }, (e: AndroidErrorCallback) => {
  473. onError && onError(e.message)
  474. })
  475. })
  476. } else {
  477. onSuccess && onSuccess()
  478. }
  479. }
  480. /**
  481. * 请求麦克风权限
  482. */
  483. requestPermissionRecordAudio(options: Partial<{ onSuccess: () => void; onError: (message: string) => void; }> = {}) {
  484. const { onSuccess, onError } = options
  485. if (this.hasPlus()) {
  486. this.onPlusReady((plus) => {
  487. plus.android.requestPermissions(['android.permission.RECORD_AUDIO'], (e: { granted: string[]; deniedPresent: string[]; deniedAlways: string[]; }) => {
  488. if (e.deniedAlways.length > 0) {
  489. onError && onError('访问麦克风被拒绝')
  490. }
  491. if (e.deniedPresent.length > 0) {
  492. onError && onError('请打开麦克风权限')
  493. }
  494. if (e.granted.length > 0) {
  495. onSuccess && onSuccess()
  496. }
  497. }, (e: AndroidErrorCallback) => {
  498. onError && onError(e.message)
  499. })
  500. })
  501. } else {
  502. onSuccess && onSuccess()
  503. }
  504. }
  505. })