소스 검색

重构拍摄界面逻辑

cc12458 7 달 전
부모
커밋
89d62b8244

BIN
public/camera/step-11.audio.wav


BIN
public/camera/step-21.audio.wav


BIN
public/camera/step-31.audio.wav


+ 150 - 0
src/assets/camera.html

@@ -0,0 +1,150 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>拍摄</title>
+  <style>
+    html, body {
+      margin: 0;
+      padding: 0;
+      width: 100vw;
+      height: 100vh;
+      position: relative;
+      overflow: hidden;
+      pointer-events: none;
+    }
+
+    canvas {
+      position: absolute;
+    }
+  </style>
+</head>
+<body class="camera-container">
+<script>
+  /**
+   * 设置视口宽度
+   * @param width
+   */
+  function updateViewportWidth(width) {
+    let metaTag = document.querySelector('meta[name="viewport"]');
+    if ( !metaTag ) {
+      metaTag = document.createElement('meta');
+      metaTag.setAttribute('name', 'viewport');
+      document.head.appendChild(metaTag);
+    }
+    const content = metaTag.getAttribute('content');
+    metaTag.setAttribute('content', content.replace(/width=[^,]+/, `width=${ width }`));
+  }
+
+  /**
+   * 媒体流
+   * @type {MediaStream}
+   */
+  let stream;
+  /**
+   * 视频元素
+   * @type {HTMLVideoElement}
+   */
+  let video;
+  /**
+   * 画布元素
+   * @type {HTMLCanvasElement}
+   */
+  let canvas;
+
+  /**
+   * @type {number}
+   */
+  let zoom;
+
+  /**
+   * @type {number}
+   */
+  let dpr;
+  let x = 0, y = 0;
+
+  /**
+   * 加载摄像头
+   * @param constraints
+   * @param constraints.width
+   * @param constraints.height
+   * @param constraints.zoom
+   * @param constraints.dpr
+   * @return {Promise<void>}
+   */
+  async function loadCamera(constraints) {
+    zoom = constraints.zoom || zoom || 1;
+    dpr = constraints.dpr || window.devicePixelRatio || 1;
+    const width = constraints.width * zoom * dpr;
+    const height = constraints.height * zoom * dpr;
+    updateViewportWidth(width);
+    document.body.style.width = `${ width }px`;
+    document.body.style.height = `${ height }px`;
+
+    stream = await navigator.mediaDevices.getUserMedia({ audio: false, video: { width, height } });
+
+    video = document.createElement('video');
+    video.srcObject = stream;
+    video.addEventListener('canplay', updateCoordinate);
+    document.body.appendChild(video);
+
+    canvas = document.createElement('canvas');
+    document.body.appendChild(canvas);
+  }
+
+  /**
+   * 获取图片
+   * @return {string} bas64
+   */
+  function handle(promise) {
+    const context = canvas.getContext('2d');
+    context.drawImage(
+      video,
+      x * dpr, y * dpr, canvas.width, canvas.height,
+      0, 0, canvas.width, canvas.height,
+    );
+    const base64 = canvas.toDataURL('image/png');
+    context.clearRect(0, 0, canvas.width, canvas.height);
+    return base64;
+  }
+
+  function updateCoordinate() {
+    const track = stream.getVideoTracks()[ 0 ];
+    const { width, height } = track.getSettings();
+
+    video.width = width;
+    video.height = height;
+    video.style.width = `${ width / dpr }px`;
+    video.style.height = `${ height / dpr }px`;
+
+    const root = document.documentElement.getBoundingClientRect();
+    const rect = video.getBoundingClientRect();
+
+    x = rect.width / 2 - root.width / 2;
+    y = rect.height / 2 - root.height / 2;
+
+    canvas.width = root.width * dpr;
+    canvas.height = root.height * dpr;
+    canvas.style.width = `${ root.width }px`;
+    canvas.style.height = `${ root.height }px`;
+    canvas.style.left = `${ x }px`;
+    canvas.style.top = `${ y }px`;
+
+    document.documentElement.scroll({ top: y, left: x, behavior: 'instant' });
+
+    if ( video.paused ) video.play();
+
+    log();
+  }
+
+  function log() {
+    const track = stream.getVideoTracks()[ 0 ];
+    const setting = track.getSettings();
+    console.log(setting);
+    console.group(`获取摄像头:`);
+    console.groupEnd();
+  }
+</script>
+</body>
+</html>

+ 3 - 0
src/modules/camera/ShadeFace.vue

@@ -41,3 +41,6 @@ watchEffect(() => {
 <style scoped lang="scss">
 svg { opacity: 0.85; }
 </style>
+<style lang="scss">
+.camera-container { clip-path: url("#shade");}
+</style>

+ 6 - 3
src/modules/camera/ShadeTongueDown.vue

@@ -3,7 +3,7 @@ import { SVGPathData, SVGPathDataTransformer } from 'svg-pathdata';
 
 
 const Shade: SVGPathData[] = [
-  'M5 195C5 195 31 45 71 12 72 11 73 9 74 8 77 5 86-1 117 5 144 9 156 7 161 5 168 2 175 1 182 3 190 5 198 9 206 18 224 39 248 87 255 153 255 153 262 192 269 195 269 195 248 311 215 332 213 333 211 334 209 336 202 341 187 350 165 350 144 349 118 350 105 351 99 351 93 350 87 348 71 341 40 323 26 280 7 221 3 205 3 205 3 205-0 195 5 195Z',
+  'M5 205C5 205 32 47 72 13 73 11 74 9 75 8 78 5 87-1 119 5 146 9 158 7 163 5 171 2 178 1 185 3 193 5 201 9 209 19 227 41 252 91 259 161 259 161 266 201 273 205 273 205 252 326 218 348 216 349 214 350 212 352 205 358 190 367 168 367 146 366 120 367 107 368 101 368 94 367 88 365 72 358 41 339 26 294 7 232 3 215 3 215 3 215 0 205 5 205Z',
 ].map(d => new SVGPathData(d));
 
 const {
@@ -34,18 +34,21 @@ watchEffect(() => {
       stroke="#fefefe"
       stroke-width="2"
       fill="none"
-      d="M5 195C5 195 31 45 71 12 72 11 73 9 74 8 77 5 86-1 117 5 144 9 156 7 161 5 168 2 175 1 182 3 190 5 198 9 206 18 224 39 248 87 255 153 255 153 262 192 269 195 269 195 248 311 215 332 213 333 211 334 209 336 202 341 187 350 165 350 144 349 118 350 105 351 99 351 93 350 87 348 71 341 40 323 26 280 7 221 3 205 3 205 3 205-0 195 5 195Z"
+      d="M5 205C5 205 32 47 72 13 73 11 74 9 75 8 78 5 87-1 119 5 146 9 158 7 163 5 171 2 178 1 185 3 193 5 201 9 209 19 227 41 252 91 259 161 259 161 266 201 273 205 273 205 252 326 218 348 216 349 214 350 212 352 205 358 190 367 168 367 146 366 120 367 107 368 101 368 94 367 88 365 72 358 41 339 26 294 7 232 3 215 3 215 3 215 0 205 5 205Z"
     />
     <path
       id="interior"
       stroke="#fefefe"
       stroke-width="2"
       fill="none"
-      d="M13 225C13 225 35 92 80 57 80 57 94 42 125 54 125 54 135 60 159 49 183 39 208 68 232 121 232 121 253 181 252 196 252 196 263 204 244 223 244 223 207 300 134 302 61 304 24 248 13 225ZM64 77C64 77 79 81 81 76 83 71 81 59 76 60M80 61 123 53 135 57 136 65C136 68 134 70 132 72 125 75 111 81 91 78 86 77 82 73 82 69L80 61ZM193 62 135 57 136 65C136 68 138 71 140 72 147 77 164 84 188 76L193 62ZM207 76C207 76 192 82 188 76M34 143C34 143 126 18 239 143M110 241C132 232 129 211 134 160 137 128 130 113 124 107 121 104 116 103 112 103 43 113 26 212 26 212M166 245C166 245 129 243 136 125 136 125 135 84 182 108 229 132 239 198 239 198M255 201C255 201 247 190 227 207 227 207 215 210 212 222 208 234 193 241 193 241 193 241 174 235 166 248 166 248 151 233 135 245 135 245 110 239 103 243 103 243 75 233 70 248L58 232C58 232 57 223 37 223 37 223 31 208 16 214M37 223C37 223 22 234 13 225M103 243C103 243 117 293 96 294 75 295 70 248 70 248 70 248 74 266 76 275 77 283 65 284 65 284L44 267C43 227 60 234 60 234M135 245C135 245 148 293 122 296 122 296 107 298 107 281M166 248C166 248 170 278 158 290 146 303 137 279 137 279M193 241C193 241 200 276 181 283 181 283 175 286 165 274M212 222C212 222 230 223 221 248 211 272 194 267 194 267M225 208 244 223"
+      d="M13 236C13 236 36 96 81 60 81 60 95 44 127 57 127 57 137 63 161 51 186 41 211 71 235 127 235 127 257 190 256 206 256 206 267 214 248 234 248 234 210 315 136 317 62 319 24 260 13 236ZM65 81C65 81 80 85 82 80 84 74 82 62 77 63M81 64 125 56 137 60 138 68C138 71 136 73 134 76 127 79 113 85 92 82 87 81 83 77 83 72L81 64ZM196 65 137 60 138 68C138 71 140 74 142 76 149 81 166 88 191 80L196 65ZM210 80C210 80 195 86 191 80M35 150C35 150 128 19 243 150M112 253C134 243 131 221 136 168 139 134 132 119 126 112 123 109 118 108 114 108 44 119 26 222 26 222M168 257C168 257 131 255 138 131 138 131 137 88 185 113 232 138 243 208 243 208M259 211C259 211 251 199 230 217 230 217 218 220 215 233 211 245 196 253 196 253 196 253 177 246 168 260 168 260 153 244 137 257 137 257 112 251 105 255 105 255 76 244 71 260L59 243C59 243 58 234 38 234 38 234 31 218 16 224M38 234C38 234 22 245 13 236M105 255C105 255 119 307 97 308 76 309 71 260 71 260 71 260 75 279 77 288 78 297 66 298 66 298L45 280C44 238 61 245 61 245M137 257C137 257 150 307 124 310 124 310 109 313 109 295M168 260C168 260 173 292 160 304 148 318 139 293 139 293M196 253C196 253 203 289 184 297 184 297 178 300 167 287M215 233C215 233 233 234 224 260 214 285 197 280 197 280M228 218 248 234"
     />
   </svg>
 </template>
 <style scoped lang="scss">
 svg { opacity: 0.85; }
 </style>
+<style lang="scss">
+.camera-container { clip-path: url("#shade");}
+</style>
 

+ 3 - 0
src/modules/camera/ShadeTongueUp.vue

@@ -56,4 +56,7 @@ watchEffect(() => {
 <style scoped lang="scss">
 svg { opacity: 0.85; }
 </style>
+<style lang="scss">
+.camera-container { clip-path: url("#shade");}
+</style>
 

+ 53 - 0
src/modules/camera/camera-result.page.vue

@@ -0,0 +1,53 @@
+<script setup lang="ts">
+import { useRouteQuery } from '@vueuse/router';
+import { useRouter }     from 'vue-router';
+
+
+defineOptions({
+  name: 'CameraResult',
+});
+
+const router = useRouter();
+const to = useRouteQuery('to', '/screen');
+const tips = computed(() => to.value.includes('screen') ? '返回首页' : '下一步');
+const countdown = ref(5);
+
+let timer: ReturnType<typeof setInterval>;
+
+function done() {
+  clearInterval(timer);
+  router.replace({ path: to.value });
+}
+
+onMounted(() => {
+  timer = setInterval(() => {
+    const _countdown = countdown.value - 1;
+    if ( _countdown <= 0 ) { done(); } else { countdown.value = _countdown; }
+  }, 1000);
+});
+
+onBeforeUnmount(() => {
+  clearInterval(timer);
+});
+</script>
+<template>
+  <div class="flex flex-col">
+    <header class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
+      <div class="text-3xl text-center">拍摄完成</div>
+    </header>
+    <main class="flex flex-col justify-evenly px-24" style="flex: 1 1 50%">
+      <img class="size-40 mx-auto" src="@/assets/images/tips.png">
+      <div>
+        <div class="text-3xl text-center">恭喜您!</div>
+        <div class="text-3xl text-center my-8">完成舌面象的采集</div>
+      </div>
+    </main>
+    <footer class="flex justify-evenly items-start" style="flex: 1 1 30%">
+      <van-button class="decorate !text-xl !text-primary-400" @click="done()">
+        {{ tips }}({{ countdown }})
+      </van-button>
+    </footer>
+  </div>
+</template>
+<style scoped lang="scss">
+</style>

+ 67 - 0
src/modules/camera/camera.config.ts

@@ -0,0 +1,67 @@
+import { getClientURL }   from '@/tools';
+import type { Component } from 'vue';
+
+
+const ShadeTongueUp = defineAsyncComponent(() => import('./ShadeTongueUp.vue'));
+const ShadeTongueDown = defineAsyncComponent(() => import('./ShadeTongueDown.vue'));
+const ShadeFace = defineAsyncComponent(() => import('./ShadeFace.vue'));
+
+
+export const DEFAULT_WIDTH = 270;
+export const DEFAULT_HEIGHT = 366;
+
+export interface ConfigProps {
+  key: string;
+  title: string;
+  label?: string;
+  description?: string;
+  required?: boolean;
+  shade: Component;
+  example?: string;
+  audio?: string;
+}
+
+function preinstall(zoom = 1, scale = 1) {
+  const width = DEFAULT_WIDTH * scale;
+  const height = DEFAULT_HEIGHT * scale;
+  return {
+    video: { width, height, zoom },
+    shade: { scaleX: scale, scaleY: scale },
+  };
+}
+
+export default {
+  ...preinstall(4, 1.5),
+  segmented: [
+    {
+      title: '舌面拍摄', key: 'upImg',
+      shade: ShadeTongueUp,
+      example: getClientURL('~/camera/step-11.example.png'),
+      audio: getClientURL('~/camera/step-11.audio.wav'),
+      required: true,
+      description: '请确保舌面无食物残渣、没有染色,舌尖向下伸直、 舌体放松、舌面平展、口张大、请避免在有色光线下拍摄。',
+      before: { label: '请将舌头放入框内,点击拍照' },
+      after: { label: '请确认照片', example: '', audio: '' },
+    },
+    {
+      title: '舌下拍摄', key: 'downImg',
+      shade: ShadeTongueDown,
+      example: getClientURL('~/camera/step-21.example.png'),
+      audio: getClientURL('~/camera/step-21.audio.wav'),
+      required: true,
+      description: '舌尖向上抵住上颚、舌体放松、口张大、露出舌下,请避免在有色光线下拍摄。',
+      before: { label: '请将舌下放入框内,点击拍照' },
+      after: { label: '请确认照片', example: '', audio: '' },
+    },
+    {
+      title: '面部拍摄', key: 'faceImg',
+      shade: ShadeFace,
+      example: getClientURL('~/camera/step-31.example.png'),
+      audio: getClientURL('~/camera/step-31.audio.wav'),
+      required: true,
+      description: '请摘下眼镜、平视前方、不要浓妆、不要遮挡面部,请避免在有色光线下拍摄。',
+      before: { label: '请将面部放入框内,点击拍照' },
+      after: { label: '请确认照片', example: '', audio: '' },
+    },
+  ] as ( ConfigProps & { before: Partial<ConfigProps>; after: Partial<ConfigProps> } )[],
+};

+ 161 - 0
src/modules/camera/camera.page.vue

@@ -0,0 +1,161 @@
+<script setup lang="ts">
+import { useTitle }                         from '@/hooks/useTitle';
+import { Dialog, Toast }                    from '@/platform';
+import { saveFileMethod, uploadFileMethod } from '@/request/api/camera.api';
+import { useVisitor }                       from '@/stores';
+import { tryOnMounted, tryOnUnmounted }     from '@vueuse/core';
+import { useForm, useRequest }              from 'alova/client';
+import Config, { type ConfigProps }         from './camera.config';
+import Camera                               from './camera.vue';
+
+
+let audio: HTMLAudioElement | void;
+
+const router = useRouter();
+const title = useTitle();
+const visitor = useVisitor();
+
+const { form: dataset, loading: submitting, send: submit } = useForm(data => saveFileMethod(data), {
+  initialForm: { patientId: visitor.patientId } as Record<string, any>,
+}).onSuccess(({ data }) => {
+  visitor.resultId = data.resultId;
+  router.replace(data.route);
+}).onError(() => {
+  handle();
+  step.value = 1;
+});
+
+
+const { video, shade, segmented } = Config;
+const step = ref(0);
+const snapshot = ref<string | void>();
+const config = shallowRef<ConfigProps>();
+
+const showExample = ref(false);
+
+watch([ step, snapshot ], ([ step, snapshot ], old, onCleanup) => {
+  const { before, after, ..._config } = segmented[ step - 1 ];
+  const old_audio = config.value?.audio;
+  config.value = Object.assign(_config, snapshot ? after : before);
+
+  if ( old_audio !== config.value.audio ) {
+    audio?.pause();
+    if ( config.value.audio && audio ) {
+      audio.src = config.value.audio;
+      audio.play()
+           .catch(() => Dialog.show({ message: '开始拍摄', theme: 'round-button' }))
+           .then(() => audio!.play());
+    }
+  }
+  title.value = config.value.title;
+});
+
+const cameraRef = useTemplateRef<InstanceType<typeof Camera>>('camera');
+
+const { loading: uploading, send: update, abort: stop } = useRequest((file: File) => uploadFileMethod(file), {
+  immediate: false,
+});
+
+const handle = () => {
+  if ( submitting.value ) return;
+  if ( !showExample.value ) { snapshot.value = cameraRef.value?.handle(); }
+  showExample.value = false;
+  stop();
+};
+const next = async () => {
+  if ( uploading.value || submitting.value ) return;
+  if ( snapshot.value ) {
+    uploading.value = true;
+    const key = config.value!.key;
+
+    const toast = Toast.loading(50, { message: '上传中...' });
+
+    const response = await fetch(snapshot.value);
+    const blob = await response.blob();
+    const file = new File(
+      [ blob ],
+      `${ dataset.value.patientId }_${ key?.replace?.(/img/ig, '') }`,
+      { type: blob.type },
+    );
+    try {
+      const { url } = await update(file);
+      dataset.value[ key ] = url;
+    } finally {
+      toast.close();
+    }
+  }
+
+  if ( step.value === segmented.length ) {
+    submit();
+  } else {
+    handle();
+    step.value += 1;
+  }
+};
+
+tryOnMounted(() => {
+  audio = document.createElement('audio');
+  document.body.appendChild(audio);
+});
+tryOnUnmounted(() => {
+  audio?.pause();
+  audio = void 0;
+
+  stop();
+});
+</script>
+<template>
+  <div class="flex flex-col">
+    <header class="flex flex-col justify-center px-24">
+      <div class="text-3xl text-center" :class="{ required: config?.required }">{{ config?.label }}</div>
+      <div class="mt-8 text-lg text-center tracking-wider leading-10">{{ config?.description }}</div>
+    </header>
+    <main class="flex justify-center items-center">
+      <Camera ref="camera" v-bind="video" @loaded="step = 1;">
+        <template #shade>
+          <component :is="config?.shade" v-bind="shade"></component>
+          <img v-if="showExample && config?.example" :src="config.example" alt="示例" @click="showExample = false" />
+        </template>
+      </Camera>
+      <div v-if="config?.example"
+           class="size-40 absolute top-4 right-4 cursor-pointer hover:text-primary"
+           @click="showExample = !showExample"
+      >
+        <img class="size-full object-scale-down" :src="config?.example" alt="示例" />
+        <div class="mt-2 text-xl text-center">示例</div>
+      </div>
+    </main>
+    <footer class="flex flex-col justify-center items-center">
+      <div v-if="snapshot" class="flex justify-evenly w-full cursor-pointer">
+        <div class="">
+          <img class="h-20" src="@/assets/images/button-cancel.png" alt="重拍" @click="handle()" /></div>
+        <div class="cursor-pointer">
+          <img class="h-20" src="@/assets/images/button-confirm.png" alt="确认" @click="next()" />
+        </div>
+      </div>
+      <div v-else-if="step" class="h-min cursor-pointer hover:text-primary" @click="handle()">
+        <button class="size-28 border-8 rounded-full hover:border-primary"></button>
+        <div class="mt-8 text-3xl">{{ showExample ? '开始拍照' : '点击拍照' }}</div>
+      </div>
+    </footer>
+  </div>
+</template>
+<style scoped lang="scss">
+header, footer {
+  flex: 1 1 20%;
+}
+
+main {
+  position: relative;;
+  flex: 1 1 40%;
+}
+
+.required {
+  &::before {
+    content: "*";
+    margin-right: 4px;
+    color: #f53030;
+  }
+
+}
+</style>

+ 39 - 168
src/modules/camera/camera.vue

@@ -1,191 +1,62 @@
 <script setup lang="ts">
-import { withResolvers }                                                  from '@/tools';
-import { getMediaStream }                                                 from '@/tools/camera.tool';
-import { tryOnMounted, tryOnUnmounted, useElementSize, useEventListener } from '@vueuse/core';
+import Camera from '@/assets/camera.html?url';
 
 
-let _stream: MediaStream;
+const { width, height, zoom, preview = true } = defineProps<{
+  width: number;
+  height: number;
+  zoom?: number;
+  preview?: boolean
+}>();
+const emits = defineEmits<{ loaded: [] }>();
+const style = computed(() => `width: ${ width }px;height: ${ height }px;`);
 
+const snapshot = ref<string | void>();
 
-const { constraints = {} } = defineProps<{ constraints?: MediaTrackConstraints, multiple?: number; }>();
-const width = defineModel('width', { default: 0 });
-const height = defineModel('height', { default: 0 });
-const multiple = defineModel('multiple', { default: 1 });
-
-const offsetX = ref(0);
-const offsetY = ref(0);
-
-watch([ width, height, multiple ], ([ w, h, m ]) => {
-  applyConstraints({ width: w * m, height: h * m });
-});
-
-
-const styleValue = computed(() => {
-  return width.value && height.value
-         ? { width: `${ width.value }px`, height: `${ height.value }px` }
-         : void 0;
-});
-
-const containerStyleValue = reactive({
-  width: `${ width.value }px`,
-  height: `${ height.value }px`,
-});
-
-const container = useTemplateRef<HTMLElement>('camera-container');
-const videoRef = ref<HTMLVideoElement | null>(null);
-const canvasRef = ref<HTMLCanvasElement | null>(null);
-
-/* 根容器 宽高 */
-const root = useElementSize(() => container.value?.parentElement);
-
-const getVideoTrack = () => {
-  return videoRef.value?.srcObject instanceof MediaStream
-         ? videoRef.value.srcObject.getVideoTracks()[ 0 ]
-         : new MediaStreamTrack();
-};
-
-useEventListener(videoRef, 'canplay', (event) => {
-  const video = event.target as HTMLVideoElement;
-  const canvas = canvasRef.value;
-
-  const track = getVideoTrack();
-
-  const settings = track.getSettings();
-  const { width: w = 1, height: h = 1, aspectRatio = w / h } = settings;
-  const reverse = aspectRatio > 1;
-  const _width = reverse ? h : w;
-  const _height = reverse ? w : h;
-  video.setAttribute('width', `${ _width }`);
-  video.setAttribute('height', `${ _height }`);
-
-  containerStyleValue.width = `${ _width }px`;
-  containerStyleValue.height = `${ _height }px`;
-
-  offsetX.value = (
-                    _width - width.value
-                  ) / 2;
-  offsetY.value = (
-                    _height - height.value
-                  ) / 2;
-
-  video.play().then(
-    () => {},
-    // (_) => (error.value = _)
-  );
-  console.group('[log] camera:', '流媒体可播放');
-  console.log(`width=${ _width }, height=${ _height }`);
-  console.log(`浏览器支持: 可缩放 ${ !!(
-    <any> navigator.mediaDevices.getSupportedConstraints()
-  ).zoom }`);
-  console.log(`摄像头支持: 可缩放 ${ !!(
-    <any> track.getCapabilities()
-  )[ 'zoom' ] }`);
-  console.log(`当前设置缩放: ${ multiple.value }`);
-  console.log(`当前实际缩放: (${ _width / width.value }, ${ _height / height.value })`);
-  console.log(`图象偏移距离: (${ offsetX.value }, ${ offsetY.value })`);
-  console.groupEnd();
-});
-
-tryOnMounted(() => {
-  const _container = container.value!;
-  canvasRef.value = container.value!.querySelector('canvas');
-  videoRef.value = container.value!.querySelector('video');
-
-  const style = getComputedStyle(_container);
-  const _width = width.value || Number.parseInt(style.width);
-  const _height = height.value || Number.parseInt(style.height);
-  const _multiple = multiple.value ?? 1;
-
-  canvasRef.value!.width = _width;
-  canvasRef.value!.height = _height;
-
-  applyConstraints({ ...constraints, width: _width * _multiple, height: _height * _multiple });
-});
-
-tryOnUnmounted(() => {
-  destroy();
-  videoRef.value = null;
-  canvasRef.value = null;
-});
-
-async function applyConstraints(constraints: Partial<MediaTrackConstraints>) {
-  const { width, height, ..._constraints } = constraints;
-  // @ts-ignore
-  if ( !!width && !!height ) {
-    destroy();
-    _stream = await getMediaStream(<any> { ..._constraints, width, height, facingMode: 'user' });
-    videoRef.value!.srcObject = _stream;
-  } else {
-    await _stream.getVideoTracks()[ 0 ]?.applyConstraints(constraints);
+const cameraFrameRef = useTemplateRef<HTMLIFrameElement & {
+  contentWindow: {
+    loadCamera(props: { width: number, height: number, zoom?: number }): Promise<void>;
+    handle(promise?: Promise<void>): string;
   }
-}
-
-function destroy() {
-  const tracks = _stream?.getTracks() ?? [];
-  for ( const track of tracks ) track.stop();
-  _stream = void 0 as any;
-  if ( videoRef.value ) {
-    videoRef.value.pause();
-    videoRef.value.srcObject = null as any;
-  }
-}
-
-
-const handle = async (promise: Promise<boolean>) => {
-  const video = videoRef.value;
-  const canvas = canvasRef.value;
-  if ( !video || !canvas ) return;
-  if ( video.paused ) await video.play();
-
-  const context = canvas.getContext('2d')!;
-  context.clearRect(0, 0, canvas.width, canvas.height);
-  context.drawImage(video, offsetX.value, offsetY.value, video.width, video.height, 0, 0, canvas.width, canvas.height);
-  video.pause();
+}>('camera-frame');
 
-  promise.then(value => video.paused && value ? video.play() : void 0);
-};
-
-const get = async (type: 'base64' | 'blob', mime = 'image/png') => {
-  const video = videoRef.value;
-  const canvas = canvasRef.value;
-  if ( !video || !canvas ) return;
-  if ( !video.paused ) await handle(Promise.resolve(true));
-
-  switch ( type ) {
-    case 'blob':
-      const { promise, resolve } = withResolvers<Blob>();
-      canvas.toBlob(<any> resolve, mime, 0.98);
-      return promise;
-    case 'base64':
-      return canvas.toDataURL(mime);
-  }
+const loadCamera = async () => {
+  await cameraFrameRef.value?.contentWindow.loadCamera?.({ width, height, zoom });
+  emits('loaded');
 };
 
 defineExpose({
-  handle, get,
+  handle() {
+    if ( !preview || !snapshot.value ) {
+      snapshot.value = cameraFrameRef.value?.contentWindow.handle?.();
+    } else {
+      snapshot.value = void 0;
+    }
+    return snapshot.value;
+  },
 });
 </script>
 <template>
-  <div class="camera-container size-full" ref="camera-container" :style="containerStyleValue">
-    <canvas style="display: none;"></canvas>
-    <video></video>
-    <slot :style="styleValue" :offsetX="offsetX" :offsetY="offsetY"></slot>
+  <div class="relative camera-container" :style="style">
+    <iframe ref="camera-frame" :src="Camera" @load="loadCamera()"></iframe>
+    <img v-if="snapshot" :src="snapshot" alt="图像" />
+    <slot name="shade" :style="style" :width="width" :height="height"></slot>
   </div>
 </template>
 <style scoped lang="scss">
 .camera-container {
-  position: absolute;
-  z-index: 0;
-  display: flex;
-  justify-content: center;
-  align-items: center;
+  iframe {
+    clip-path: url("#shade");
+  }
 
-  > * {
-    position: absolute;
+  img {
+    object-fit: scale-down;
   }
 
-  video {
-    clip-path: url("#shade");
+  > * {
+    position: absolute;
+    width: 100%;
+    height: 100%;
   }
 }
 </style>

+ 0 - 66
src/modules/camera/config.ts

@@ -1,66 +0,0 @@
-import { getClientURL }   from '@/tools';
-import type { Component } from 'vue';
-
-
-const ShadeTongueUp = defineAsyncComponent(() => import('./ShadeTongueUp.vue'));
-const ShadeTongueDown = defineAsyncComponent(() => import('./ShadeTongueDown.vue'));
-const ShadeFace = defineAsyncComponent(() => import('./ShadeFace.vue'));
-
-export interface ConfigProps {
-  key: string;
-  title: string;
-  required?: boolean;
-  description?: string;
-  example?: string;
-  shade?: Component;
-  before?: {
-    label?: string;
-    description?: string;
-    example?: string;
-  };
-  after?: {
-    label?: string;
-    description?: string;
-    example?: string;
-  };
-}
-
-export default [
-  {
-    title: '舌面拍摄', key: 'upImg',
-    shade: ShadeTongueUp,
-    example: getClientURL('~/camera/step-11.example.png'),
-    required: true,
-    description:
-      '请将舌头请确保舌面无食物残渣、没有染色,舌尖向下伸直、 舌体放松、舌面平展、口张大、请避免在有色光线下拍摄。',
-    before: { label: '请将舌头放入框内,点击拍照' },
-    after: {
-      label: '请确认照片',
-      example: '',
-    },
-  },
-  {
-    title: '舌下拍摄', key: 'downImg',
-    shade: ShadeTongueDown,
-    example: getClientURL('~/camera/step-21.example.png'),
-    required: true,
-    description: '舌尖向上抵住上颚、舌体放松、口张大、露出舌下,请避免在有色光线下拍摄。',
-    before: { label: '请将舌下放入框内,点击拍照' },
-    after: {
-      label: '请确认照片',
-      example: '',
-    },
-  },
-  {
-    title: '面部拍摄', key: 'faceImg',
-    shade: ShadeFace,
-    example: getClientURL('~/camera/step-31.example.png'),
-    required: true,
-    description: '请摘下眼镜、平视前方、不要浓妆、不要遮挡面部,请避免在有色光线下拍摄。',
-    before: { label: '请将面部放入框内,点击拍照' },
-    after: {
-      label: '请确认照片',
-      example: '',
-    },
-  },
-] as ConfigProps[];

+ 0 - 223
src/modules/camera/page.vue

@@ -1,223 +0,0 @@
-<script setup lang="ts">
-import { useTitle }                         from '@/hooks/useTitle';
-import { saveFileMethod, uploadFileMethod } from '@/request/api/camera.api';
-import { useVisitor }                       from '@/stores';
-import { withResolvers }                    from '@/tools';
-import { tryOnUnmounted }                   from '@vueuse/core';
-import { useForm, useRequest }              from 'alova/client';
-import Camera                               from './camera.vue';
-import type { ConfigProps }                 from './config';
-import Config                               from './config';
-
-
-defineOptions({
-  name: 'CameraPage',
-});
-const title = useTitle();
-
-const step = ref(0);
-const config = shallowRef<Omit<ConfigProps, 'before' | 'after'> & { type: 'before' | 'after'; label: string; }>();
-
-const camera = useTemplateRef<InstanceType<typeof Camera>>('camera');
-const area = reactive({
-  // width: 295,
-  // height: 400,
-  width: 270,
-  height: 366,
-  multiple: 2,
-});
-
-onBeforeMount(() => next());
-
-function next(half = false) {
-  if ( half ) {
-    const { after, before, ..._config } = Config[ step.value - 1 ];
-    config.value = {
-      ..._config, label: _config.title,
-      ...after, type: 'after',
-    };
-  } else {
-    const { after, before, ..._config } = Config[ step.value ];
-    config.value = {
-      ..._config, label: _config.title,
-      ...before, type: 'before',
-    };
-    step.value += 1;
-  }
-  title.value = config.value.title;
-}
-
-function prev(half = false) {
-  if ( half ) {
-    const { after, before, ..._config } = Config[ step.value - 1 ];
-    config.value = {
-      ..._config, label: _config.title,
-      ...before, type: 'before',
-    };
-  } else {
-    const { after, before, ..._config } = Config[ step.value - 2 ];
-    config.value = {
-      ..._config, label: _config.title,
-      ...before, type: 'before',
-    };
-    step.value -= 1;
-  }
-}
-
-let done = () => {};
-
-const visitor = useVisitor();
-let play: (next: boolean) => void;
-let timer: ReturnType<typeof setInterval>;
-
-const router = useRouter();
-const complete = ref(false);
-const countdown = ref(0);
-
-const { form: dataset, loading: submitting, send: submit } = useForm(data => saveFileMethod(data), {
-  initialForm: { patientId: visitor.patientId } as Record<string, any>,
-}).onSuccess(({ data }) => {
-  done = () => { router.replace(data.path); };
-  visitor.resultId = data.resultId;
-  if ( data.done ) {
-    complete.value = true;
-    countdown.value = 5;
-    timer = setInterval(() => { if ( countdown.value-- <= 0 ) done(); }, 1000);
-  } else {
-    done();
-  }
-});
-
-const { loading: uploading, send: update, abort: stop } = useRequest((file: File) => uploadFileMethod(file), {
-  immediate: false,
-});
-
-
-const onStart = () => {
-  if ( showExample.value ) return showExample.value = false;
-  const resolvers = withResolvers<boolean>();
-  play = resolvers.resolve;
-  camera.value?.handle(resolvers.promise);
-  next(true);
-};
-
-
-const onConfirm = async () => {
-  if ( submitting.value ) return;
-
-  const key = config.value!.key;
-  const title = config.value!.title;
-  // 创建文件
-  const blob = await camera.value?.get('blob') as Blob;
-  const file = new File([ blob ], `${ dataset.value.patientId }-${ title.slice(0, 2) }`, { type: blob.type });
-  // 上传文件
-  try {
-    const { url } = await update(file);
-    dataset.value[ key ] = url;
-    if ( step.value == Config.length ) {
-      submit();
-    } else {
-      next();
-      play(true);
-    }
-  } catch ( error: any ) {
-    console.log(`[log] 拍照上传错误`, error?.message ?? error);
-    onCancel();
-    play(true);
-  }
-};
-const onCancel = () => {
-  if ( submitting.value ) return;
-
-  prev(true);
-  play?.(true);
-
-  stop();
-};
-
-tryOnUnmounted(() => {
-  clearInterval(timer);
-  stop();
-});
-
-const showExample = ref(false);
-const transparency = ref(1);
-</script>
-<template>
-  <div class="py-8 flex flex-col">
-    <template v-if="complete">
-      <header class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
-        <div class="text-3xl text-center">拍摄完成</div>
-      </header>
-      <main class="flex flex-col justify-evenly px-24" style="flex: 1 1 50%">
-        <img class="size-40 mx-auto" src="@/assets/images/tips.png">
-        <div>
-          <div class="text-3xl text-center">恭喜您!</div>
-          <div class="text-3xl text-center my-8">完成舌面象的采集</div>
-        </div>
-      </main>
-      <footer class="flex justify-evenly items-start" style="flex: 1 1 30%">
-        <van-button class="decorate !text-xl !text-primary-400" @click="done()">
-          返回首页({{ countdown }})
-        </van-button>
-      </footer>
-    </template>
-    <template v-else>
-      <header class="flex flex-col justify-center px-24" style="flex: 1 1 20%">
-        <div class="text-3xl text-center" :class="{ required: config?.required }">{{ config?.label }}</div>
-        <div class="mt-8 text-lg text-center tracking-wider leading-10">{{ config?.description }}</div>
-        <div>w: {{ area.width }}, h: {{ area.height }}, m: {{ area.multiple }}</div>
-      </header>
-      <main class="relative flex justify-center items-center" style="flex: 1 1 40%">
-        <Camera ref="camera" v-model:width="area.width" v-model:height="area.height" v-model:multiple="area.multiple">
-          <template #default="{style, offsetX, offsetY}">
-            <component :is="config?.shade" :style="style" :translateX="offsetX" :translateY="offsetY"></component>
-            <img
-              v-if="showExample"
-              class="example"
-              :style="{ opacity: transparency, ...style }"
-              :src="config?.example"
-              alt="示例"
-              @click="showExample = false"
-            />
-          </template>
-        </Camera>
-        <div
-          v-if="config?.example"
-          class="size-40 absolute top-4 right-4 cursor-pointer hover:text-primary"
-          @click="(showExample = !showExample) && (transparency = 1)"
-        >
-          <img class="size-full object-scale-down" :src="config?.example" alt="示例" />
-          <div class="mt-2 text-xl text-center">示例</div>
-        </div>
-      </main>
-      <footer class="flex justify-evenly items-center" style="flex: 1 1 20%">
-        <template v-if="config?.type === 'after'">
-          <div class="cursor-pointer">
-            <img class="h-20" src="@/assets/images/button-cancel.png" alt="重拍" @click="onCancel()" /></div>
-          <div class="cursor-pointer">
-            <img class="h-20" src="@/assets/images/button-confirm.png" alt="确认" @click="onConfirm()" />
-          </div>
-        </template>
-        <div v-else-if="config?.type === 'before'" class="h-min cursor-pointer hover:text-primary" @click="onStart()">
-          <button class="size-28 border-8 rounded-full hover:border-primary"></button>
-          <div class="mt-8 text-3xl">{{ showExample && transparency === 1 ? '开始拍照' : '点击拍照' }}</div>
-        </div>
-      </footer>
-    </template>
-  </div>
-</template>
-<style scoped lang="scss">
-.required {
-  &::before {
-    content: "*";
-    margin-right: 4px;
-    color: #f53030;
-  }
-}
-
-header, footer {
-  position: relative;
-  z-index: 11;
-}
-</style>

+ 10 - 0
src/platform/dialog.ui.ts

@@ -0,0 +1,10 @@
+import type { DialogOptions as VantDialogOptions } from 'vant';
+import { showDialog }                              from 'vant';
+import 'vant/es/dialog/style';
+
+
+export class Dialog {
+  static show(options: VantDialogOptions) {
+    return showDialog(options);
+  }
+}

+ 1 - 0
src/platform/index.ts

@@ -17,5 +17,6 @@ export function getSerialNumberSync() {
   )();
 }
 
+export * from './dialog.ui';
 export * from './notify.ui';
 export * from './toast.ui';

+ 4 - 2
src/request/api/camera.api.ts

@@ -15,8 +15,10 @@ export function saveFileMethod(params: Record<string, string>) {
       const path = await processMethod(`/camera`);
       return {
         resultId: data,
-        done: !path,
-        path: path || `/screen`,
+        route: path ? { path } : {
+          path: '/camera/result',
+          query: { to: `/screen` },
+        },
       };
     },
   });

+ 2 - 1
src/router/index.ts

@@ -11,7 +11,8 @@ const router = createRouter({
       path: '/',
       children: [
         { path: 'register', component: () => import('@/pages/register.page.vue'), meta: { title: '建档' } },
-        { path: 'camera', component: () => import('@/modules/camera/page.vue'), meta: { title: '拍摄2' } },
+        { path: 'camera', component: () => import('@/modules/camera/camera.page.vue'), meta: { title: '拍摄' } },
+        { path: 'camera/result', component: () => import('@/modules/camera/camera-result.page.vue'), meta: { title: '拍摄完成' } },
         { path: 'questionnaire', component: () => import('@/modules/questionnaire/page.vue'), meta: { title: '问卷' } },
         { path: 'report/:id/scheme', component: () => import('@/modules/report/scheme.page.vue'), meta: { title: '调理方案' } },
         { path: 'report/:id', component: () => import('@/modules/report/report.page.vue'), meta: { title: '健康分析报告' } },