poster.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. <template>
  2. <view class="popup-container" v-if="value" @click="close">
  3. <view class="popup-wrapper">
  4. <view class="popup-title" v-if="showTitle">您已经成为亿职赞第{{ rank }}位金牌推荐官</view>
  5. <view class="poster-wrapper">
  6. <image :src="posterImageSrc" class="poster-img" mode="aspectFit"></image>
  7. </view>
  8. <view class="poster-tip" v-if="showTip">推荐者的个人信息不会公开展示,请放心推荐</view>
  9. <view class="button" @click.stop="handleSave">{{ saveBtnText }}</view>
  10. </view>
  11. </view>
  12. </template>
  13. <script>
  14. export default {
  15. props: {
  16. value: {
  17. type: Boolean,
  18. default: false
  19. },
  20. rank: {
  21. type: Number,
  22. default: 0
  23. },
  24. posterBase64Url: {
  25. type: String,
  26. default: ''
  27. },
  28. /** 纯 base64 时的图片类型,默认 png;若后端是 jpeg 可传 image/jpeg */
  29. posterMime: {
  30. type: String,
  31. default: 'image/png'
  32. },
  33. showTitle: {
  34. type: Boolean,
  35. default: true
  36. },
  37. showTip: {
  38. type: Boolean,
  39. default: true
  40. },
  41. saveBtnText: {
  42. type: String,
  43. default: '保存海报并成为推荐官'
  44. },
  45. },
  46. computed: {
  47. posterImageSrc() {
  48. const raw = this.posterBase64Url
  49. if (!raw || typeof raw !== 'string') return ''
  50. const t = raw.trim()
  51. if (
  52. t.startsWith('data:image') ||
  53. t.startsWith('http://') ||
  54. t.startsWith('https://') ||
  55. t.startsWith('/') ||
  56. t.startsWith('@/')
  57. ) {
  58. return t
  59. }
  60. // 去掉换行空格,拼成 data URL 供 image 显示
  61. const b64 = t.replace(/\s/g, '')
  62. return `data:${this.posterMime};base64,${b64}`
  63. }
  64. },
  65. data() {
  66. return {
  67. num: 990
  68. }
  69. },
  70. methods: {
  71. close() {
  72. this.$emit('close')
  73. },
  74. handleSave() {
  75. const src = this.posterImageSrc
  76. if (!src) {
  77. uni.showToast({ title: '暂无海报', icon: 'none' })
  78. return
  79. }
  80. // #ifdef H5
  81. this._savePosterH5(src)
  82. // #endif
  83. // #ifdef APP-PLUS
  84. this._savePosterApp(src)
  85. // #endif
  86. },
  87. // #ifdef H5
  88. _savePosterH5(src) {
  89. if (src.startsWith('data:image')) {
  90. try {
  91. const parts = src.split(',')
  92. const mimeMatch = parts[0].match(/:(.*?);/)
  93. const mime = mimeMatch ? mimeMatch[1] : 'image/png'
  94. const bstr = atob(parts[1])
  95. const n = bstr.length
  96. const u8arr = new Uint8Array(n)
  97. for (let i = 0; i < n; i++) u8arr[i] = bstr.charCodeAt(i)
  98. const blob = new Blob([u8arr], { type: mime })
  99. const url = URL.createObjectURL(blob)
  100. const a = document.createElement('a')
  101. a.href = url
  102. a.download = 'poster.' + (mime.indexOf('jpeg') !== -1 ? 'jpg' : 'png')
  103. document.body.appendChild(a)
  104. a.click()
  105. document.body.removeChild(a)
  106. URL.revokeObjectURL(url)
  107. uni.showToast({ title: '已开始下载' })
  108. this.$emit('saved')
  109. } catch (e) {
  110. uni.showToast({ title: '保存失败', icon: 'none' })
  111. }
  112. return
  113. }
  114. if (src.startsWith('http://') || src.startsWith('https://')) {
  115. uni.showLoading({ title: '保存中...', mask: true })
  116. fetch(src, { mode: 'cors' })
  117. .then((res) => {
  118. if (!res.ok) throw new Error('fetch fail')
  119. return res.blob()
  120. })
  121. .then((blob) => {
  122. uni.hideLoading()
  123. const url = URL.createObjectURL(blob)
  124. const a = document.createElement('a')
  125. a.href = url
  126. a.download = 'poster.png'
  127. document.body.appendChild(a)
  128. a.click()
  129. document.body.removeChild(a)
  130. URL.revokeObjectURL(url)
  131. uni.showToast({ title: '已开始下载' })
  132. this.$emit('saved')
  133. })
  134. .catch(() => {
  135. uni.hideLoading()
  136. uni.showToast({
  137. title: '无法下载,请长按图片保存',
  138. icon: 'none'
  139. })
  140. })
  141. return
  142. }
  143. uni.showToast({ title: '请使用网络图或 base64 图片', icon: 'none' })
  144. },
  145. // #endif
  146. // #ifdef APP-PLUS
  147. /**
  148. * App 保存难在:① 相册权限各版本 Android 规则不同,误检会拦死;② saveImageToPhotosAlbum 多数机型要传 _doc/相对路径而非乱转 file://。
  149. * 这里按 DCloud 文档最小路径:Bitmap → _doc/xxx.png → uni.saveImageToPhotosAlbum(先试相对路径)。
  150. */
  151. async _savePosterApp(src) {
  152. const allowed = await this._ensureAlbumPermissionForSave()
  153. if (!allowed) return
  154. uni.showLoading({ title: '保存中...', mask: true })
  155. const hide = () => {
  156. try {
  157. uni.hideLoading()
  158. } catch (e) {}
  159. }
  160. try {
  161. await this._withTimeout(this._savePosterAppCore(src), 50000, '保存超时,请重试')
  162. hide()
  163. uni.showToast({ title: '已保存到相册', icon: 'success' })
  164. this.$emit('saved')
  165. } catch (e) {
  166. hide()
  167. const msg = String((e && (e.errMsg || e.message)) || '保存失败')
  168. if (/auth|permission|denied|拒绝|authorize/i.test(msg)) {
  169. this._openAlbumSettingsModal()
  170. } else {
  171. uni.showToast({ title: msg.length > 36 ? '保存失败' : msg, icon: 'none' })
  172. }
  173. }
  174. },
  175. _withTimeout(p, ms, errMsg) {
  176. return Promise.race([
  177. Promise.resolve(p),
  178. new Promise((_, reject) =>
  179. setTimeout(() => reject(new Error(errMsg || '操作超时')), ms)
  180. )
  181. ])
  182. },
  183. async _savePosterAppCore(src) {
  184. let local = ''
  185. if (src.startsWith('data:image')) {
  186. local = await this._bitmapDataUrlToDocRel(src)
  187. } else if (src.startsWith('http://') || src.startsWith('https://')) {
  188. local = await new Promise((resolve, reject) => {
  189. uni.downloadFile({
  190. url: src,
  191. success: (r) => {
  192. if (r.statusCode === 200 && r.tempFilePath) resolve(r.tempFilePath)
  193. else reject(new Error('图片下载失败'))
  194. },
  195. fail: () => reject(new Error('图片下载失败'))
  196. })
  197. })
  198. } else {
  199. local = src
  200. }
  201. await this._saveImageToAlbumTryPaths(this._albumPathCandidates(local))
  202. },
  203. /** 官方示例:loadBase64Data → save 到 _doc,返回相对路径 */
  204. _bitmapDataUrlToDocRel(dataUrl) {
  205. return new Promise((resolve, reject) => {
  206. const name = 'poster_' + Date.now() + '.png'
  207. const rel = '_doc/' + name
  208. const id = 'posterBitmap' + Date.now()
  209. const bm = new plus.nativeObj.Bitmap(id)
  210. bm.loadBase64Data(
  211. dataUrl,
  212. () => {
  213. bm.save(
  214. rel,
  215. { overwrite: true },
  216. () => {
  217. try {
  218. bm.clear()
  219. } catch (e) {}
  220. resolve(rel)
  221. },
  222. (err) => {
  223. try {
  224. bm.clear()
  225. } catch (e) {}
  226. reject(err || new Error('生成临时图失败'))
  227. }
  228. )
  229. },
  230. (err) => {
  231. try {
  232. bm.clear()
  233. } catch (e) {}
  234. reject(err || new Error('图片无法解析'))
  235. }
  236. )
  237. })
  238. },
  239. _albumPathCandidates(p) {
  240. const list = []
  241. const add = (x) => {
  242. if (x && list.indexOf(x) === -1) list.push(x)
  243. }
  244. add(p)
  245. if (p && p.startsWith('file://')) {
  246. add(p.replace(/^file:\/\//, ''))
  247. }
  248. if (typeof plus !== 'undefined' && plus.io && p) {
  249. try {
  250. add(plus.io.convertLocalFileSystemURL(p))
  251. } catch (e) {}
  252. }
  253. return list
  254. },
  255. _saveImageToAlbumTryPaths(paths) {
  256. if (!paths.length) {
  257. return Promise.reject(new Error('没有可保存的路径'))
  258. }
  259. return paths.reduce(
  260. (acc, fp) =>
  261. acc.catch(() =>
  262. new Promise((resolve, reject) => {
  263. uni.saveImageToPhotosAlbum({
  264. filePath: fp,
  265. success: () => resolve(),
  266. fail: (err) => reject(err || new Error('保存失败'))
  267. })
  268. })
  269. ),
  270. Promise.reject(new Error('_'))
  271. )
  272. },
  273. _openAlbumSettingsModal() {
  274. uni.showModal({
  275. title: '需要相册权限',
  276. content: '保存图片到相册需要相册/存储权限,请在系统设置中开启后重试。',
  277. confirmText: '去设置',
  278. cancelText: '取消',
  279. success: (r) => {
  280. if (r.confirm && typeof uni.openAppAuthorizeSetting === 'function') {
  281. uni.openAppAuthorizeSetting({})
  282. }
  283. }
  284. })
  285. },
  286. /** 是否具备保存到相册所需权限;否:已弹窗引导,返回 false */
  287. _ensureAlbumPermissionForSave() {
  288. return new Promise((resolve) => {
  289. if (typeof plus === 'undefined') {
  290. resolve(true)
  291. return
  292. }
  293. const os = plus.os.name
  294. if (os === 'iOS') {
  295. resolve(this._ensureIosAlbumPermissionForSave())
  296. return
  297. }
  298. if (os === 'Android') {
  299. this._ensureAndroidAlbumPermissionForSave().then(resolve)
  300. return
  301. }
  302. resolve(true)
  303. })
  304. },
  305. _ensureIosAlbumPermissionForSave() {
  306. try {
  307. if (typeof uni.getAppAuthorizeSetting !== 'function') {
  308. return true
  309. }
  310. const s = uni.getAppAuthorizeSetting() || {}
  311. const a = s.albumAuthorized
  312. if (a === 'authorized' || a === 'limited') {
  313. return true
  314. }
  315. if (a === 'denied') {
  316. this._openAlbumSettingsModal()
  317. return false
  318. }
  319. // not determined / 未返回:首次保存时由系统弹窗授权
  320. return true
  321. } catch (e) {
  322. return true
  323. }
  324. },
  325. _androidCheckPermissions(permissions) {
  326. try {
  327. const main = plus.android.runtimeMainActivity()
  328. const PM = plus.android.importClass('android.content.pm.PackageManager')
  329. const granted = PM.PERMISSION_GRANTED
  330. for (let i = 0; i < permissions.length; i++) {
  331. if (main.checkSelfPermission(permissions[i]) !== granted) {
  332. return false
  333. }
  334. }
  335. return true
  336. } catch (e) {
  337. return false
  338. }
  339. },
  340. /**
  341. * Android 10+ 分区存储下,写入相册多走系统 MediaStore,预检 READ_MEDIA_IMAGES 常误判且与「保存」无关,会导致永远进不了保存。
  342. * 仅对 Android 9 及以下尝试申请 WRITE_EXTERNAL_STORAGE;其余交给 saveImageToPhotosAlbum 与系统弹窗。
  343. */
  344. _ensureAndroidAlbumPermissionForSave() {
  345. return new Promise((resolve) => {
  346. if (!plus.android) {
  347. resolve(true)
  348. return
  349. }
  350. try {
  351. const Build = plus.android.importClass('android.os.Build')
  352. if (Build.VERSION.SDK_INT >= 29) {
  353. resolve(true)
  354. return
  355. }
  356. if (Build.VERSION.SDK_INT < 23) {
  357. resolve(true)
  358. return
  359. }
  360. } catch (e) {
  361. resolve(true)
  362. return
  363. }
  364. if (typeof plus.android.requestPermissions !== 'function') {
  365. resolve(true)
  366. return
  367. }
  368. const perms = ['android.permission.WRITE_EXTERNAL_STORAGE']
  369. if (this._androidCheckPermissions(perms)) {
  370. resolve(true)
  371. return
  372. }
  373. plus.android.requestPermissions(
  374. perms,
  375. () => {
  376. if (this._androidCheckPermissions(perms)) {
  377. resolve(true)
  378. } else {
  379. this._openAlbumSettingsModal()
  380. resolve(false)
  381. }
  382. },
  383. () => {
  384. this._openAlbumSettingsModal()
  385. resolve(false)
  386. }
  387. )
  388. })
  389. },
  390. // #endif
  391. }
  392. }
  393. </script>
  394. <style lang="scss" scoped>
  395. .popup-container {
  396. position: fixed;
  397. left: 0;
  398. top: 0;
  399. width: 100vw;
  400. height: 100vh;
  401. background: rgba(0, 0, 0, 0.6);
  402. display: flex;
  403. align-items: center;
  404. justify-content: center;
  405. .popup-wrapper {
  406. .popup-title {
  407. color: rgba(255, 255, 255, 1);
  408. font-style: Bold;
  409. font-size: 40rpx;
  410. font-weight: 700;
  411. line-height: 1.6;
  412. }
  413. .poster-wrapper {
  414. width: 488rpx;
  415. height: 1028rpx;
  416. border-radius: 20rpx;
  417. margin: 64rpx auto 32rpx;
  418. .poster-img {
  419. width: 100%;
  420. height: 100%;
  421. }
  422. }
  423. .poster-tip {
  424. color: rgba(255, 255, 255, 1);
  425. font-size: 24rpx;
  426. font-weight: 400;
  427. line-height: 40rpx;
  428. text-align: center;
  429. }
  430. .button {
  431. border-radius: 126px;
  432. background: rgba(255, 255, 255, 1);
  433. margin: 24rpx 30rpx;
  434. text-align: center;
  435. line-height: 90rpx;
  436. color: rgba(0, 0, 0, 1);
  437. font-size: 32rpx;
  438. font-weight: 400;
  439. }
  440. }
  441. }
  442. </style>