Explorar el Código

完成拍照留痕功能开发

yuanmingze hace 8 meses
padre
commit
aa2fe7143f

+ 2 - 2
src/composables/photo/useSaveToAlbum.ts

@@ -16,7 +16,7 @@ export function useSaveToAlbum() {
     return !!open.authSetting['scope.writePhotosAlbum']
   }
 
-  async function saveImage(filePath: string): Promise<void> {
+  async function saveToAlbum(filePath: string): Promise<void> {
     const ok = await ensureAlbumPermission()
     if (!ok) return
 
@@ -24,5 +24,5 @@ export function useSaveToAlbum() {
     uni.showToast({ title: '保存成功', icon: 'success' })
   }
 
-  return { saveImage }
+  return { saveToAlbum }
 }

+ 216 - 0
src/pages/index/components/ImagePreviewOverlay.vue

@@ -0,0 +1,216 @@
+<template>
+  <view v-if="visible" class="preview-mask" @touchstart.stop @touchmove.stop>
+    <view class="preview-header" :style="{ paddingTop: safeTop + 'px' }">
+      <view class="left" @touchstart.stop="close">‹</view>
+      <view class="title">图片预览</view>
+    </view>
+
+    <view class="preview-body">
+      <image v-if="watermarkSrc" class="preview-image" :src="watermarkSrc" mode="scaleToFill" />
+    </view>
+
+    <view class="preview-footer" :style="{ paddingBottom: safeBottom + 'px' }">
+      <view class="btn" @touchstart.stop="$emit('retake')">重拍</view>
+      <view class="btn primary" @touchstart.stop="download">下载照片</view>
+    </view>
+
+    <!-- 隐藏 canvas -->
+    <canvas
+      canvas-id="watermarkCanvas"
+      :style="{
+        width: canvasWidth + 'px',
+        height: canvasHeight + 'px',
+        position: 'fixed',
+        left: '-9999px',
+      }"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue'
+import { getCurrentInstance } from 'vue'
+
+import dayjs from 'dayjs'
+
+import { useSaveToAlbum } from '@/composables/photo/useSaveToAlbum'
+
+const visible = defineModel<boolean>('visible', { default: false })
+
+const props = defineProps<{
+  src: string
+  address: string
+}>()
+
+defineEmits<{
+  (e: 'retake'): void
+}>()
+
+const system = uni.getSystemInfoSync()
+const safeTop = system.safeAreaInsets?.top ?? 0
+const safeBottom = system.safeAreaInsets?.bottom ?? 0
+
+const watermarkSrc = ref('')
+const canvasWidth = ref(0)
+const canvasHeight = ref(0)
+
+const close = () => {
+  visible.value = false
+}
+
+const { saveToAlbum } = useSaveToAlbum()
+
+const download = () => {
+  if (!watermarkSrc.value) return
+  saveToAlbum(watermarkSrc.value)
+}
+
+/**
+ * 核心:生成水印图
+ */
+
+const instance = getCurrentInstance()
+const generateWatermark = (fileUrl: string, address: string) => {
+  console.log('fileUrl', fileUrl)
+  console.log('address', address)
+
+  return new Promise<string>((resolve, reject) => {
+    uni.getImageInfo({
+      src: fileUrl,
+      success(res) {
+        canvasWidth.value = res.width
+        canvasHeight.value = res.height
+
+        const ctx = uni.createCanvasContext('watermarkCanvas', instance)
+
+        // 原图
+        ctx.drawImage(fileUrl, 0, 0, res.width, res.height)
+
+        const scale = res.width / 375
+        const padding = 12 * scale
+        const fontSize = 12 * scale
+        const lineHeight = 18 * scale
+
+        const textList = [address, dayjs().format('YYYY/MM/DD HH:mm:ss')]
+
+        ctx.setFontSize(fontSize)
+        const textWidth = Math.max(...textList.map((t) => ctx.measureText(t).width))
+
+        const boxWidth = textWidth + padding * 2
+        const boxHeight = lineHeight * textList.length + padding
+
+        const x = padding
+        const y = res.height - boxHeight - padding
+
+        ctx.setFillStyle('rgba(0,0,0,0.45)')
+        ctx.fillRect(x, y, boxWidth, boxHeight)
+
+        ctx.setFillStyle('#fff')
+        textList.forEach((text, i) => {
+          ctx.fillText(text, x + padding, y + padding + lineHeight * (i + 1) - 4)
+        })
+
+        ctx.draw(false, () => {
+          uni.canvasToTempFilePath(
+            {
+              canvasId: 'watermarkCanvas',
+              fileType: 'jpg',
+              success(r) {
+                resolve(r.tempFilePath)
+              },
+              fail: reject,
+            },
+            instance
+          )
+        })
+      },
+      fail: reject,
+    })
+  })
+}
+
+/**
+ * src 或 address 变化时,重新生成水印
+ */
+watch(
+  () => [props.src, props.address],
+  async ([src, address]) => {
+    if (!src || !address) return
+    try {
+      watermarkSrc.value = await generateWatermark(src, address)
+      console.log('watermarkSrc.value', watermarkSrc.value)
+    } catch (error) {
+      console.log('err', error)
+    }
+  },
+  { immediate: true }
+)
+watch(visible, (val) => {
+  if (val) {
+    uni.hideTabBar({ animation: false })
+  } else {
+    uni.showTabBar({ animation: false })
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.preview-mask {
+  position: fixed;
+  inset: 0;
+  background: #000;
+  z-index: 9999;
+  display: flex;
+  flex-direction: column;
+}
+
+.preview-header {
+  height: 88rpx;
+  display: flex;
+  align-items: center;
+  color: #fff;
+  padding: 0 24rpx;
+  box-sizing: content-box;
+
+  .left {
+    width: 88rpx;
+    font-size: 40rpx;
+  }
+
+  .title {
+    flex: 1;
+    text-align: center;
+    font-size: 32rpx;
+  }
+}
+
+.preview-body {
+  height: calc(100vh - 88rpx - 128rpx);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  .preview-image {
+    height: calc(100vh - 88rpx - 128rpx);
+    max-width: 100%;
+    width: 750rpx;
+  }
+}
+
+.preview-footer {
+  height: 128rpx;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 0 48rpx;
+  color: #fff;
+
+  .btn {
+    font-size: 32rpx;
+  }
+
+  .primary {
+    font-weight: 500;
+  }
+}
+</style>

+ 35 - 13
src/pages/index/components/WorkbenchTaskSection.vue

@@ -1,6 +1,6 @@
 <template>
-  <view class="workbench-task-section" @click="onTakePhoto">
-    <view class="workbench-task-section-header">
+  <view class="workbench-task-section">
+    <view class="workbench-task-section-header" @click.self.stop="onTakePhoto">
       <view class="workbench-task-section-title">任务实施</view>
       <view class="workbench-task-section-action">
         <wd-icon name="photo" color="#fff" size="40rpx"></wd-icon>
@@ -19,6 +19,13 @@
       >
       </view>
     </view>
+
+    <ImagePreviewOverlay
+      v-model:visible="previewVisible"
+      :src="imageUrl"
+      :address="address"
+      @retake="retake"
+    />
   </view>
 </template>
 
@@ -26,11 +33,11 @@
 import { computed, ref } from 'vue'
 
 import { getLocation } from '@/lib/location'
-import { debounce } from '@/plugins/debounce'
 
 import { takePhoto } from '@/utils/image'
 
 import { DEFAULT_WORKBENCH_TASKS, INVESTMENT_MANAGER_TASKS } from '../taskList/index'
+import ImagePreviewOverlay from './ImagePreviewOverlay.vue'
 
 const userRole = computed(() => {
   // return store.user.role
@@ -44,29 +51,44 @@ const currentTaskList = computed(() => {
   return DEFAULT_WORKBENCH_TASKS
 })
 
+const previewVisible = ref(false)
+const imageUrl = ref('')
+const address = ref('')
 const takePhotoFlow = async (): Promise<void> => {
-  // 1. 校验位置(业务前置条件)
   const locationResult = await getLocation()
-  console.log('res', locationResult)
+  console.log('locationResult', locationResult)
 
   if (!locationResult?.address) {
     return
   }
+  address.value = locationResult.address
   try {
-    // 2. 拉起相机
     const imageSrc = await takePhoto()
-    console.log('imageSrc', imageSrc)
+
+    previewVisible.value = true
+    imageUrl.value = imageSrc
   } catch {
     // 用户取消 / 拍照失败 / 格式不支持
-    // 业务要求:静默结束
   }
 }
 
-/**
- * 对外绑定的点击函数(防抖)
- * - 防止连续点击多次拉起相机
- */
-const onTakePhoto = debounce(takePhotoFlow)
+const retake = (): void => {
+  previewVisible.value = false
+  imageUrl.value = ''
+
+  takePhotoFlow()
+}
+
+const taking = ref(false)
+const onTakePhoto = async () => {
+  if (taking.value) return
+  taking.value = true
+  try {
+    await takePhotoFlow()
+  } finally {
+    taking.value = false
+  }
+}
 </script>
 
 <style lang="scss" scoped>